Merge branch 'v2.0v2.0' into config-return-behavor

# Conflicts:
#	src/htmx.js
This commit is contained in:
Carson Gross 2023-12-14 11:59:14 -07:00
commit b9dbbf52ca
65 changed files with 1904 additions and 441 deletions

18
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,18 @@
## Description
*Please describe what changes you made, and why you feel they are necessary. Make sure to include
code examples, where applicable.*
Corresponding issue:
## Testing
*Please explain how you tested this change manually, and, if applicable, what new tests you added. If
you're making a change to just the website, you can omit this section.*
## Checklist
* [ ] I have read the contribution guidelines
* [ ] I have targeted this PR against the correct branch (`master` for website changes, `dev` for
source changes)
* [ ] This is either a bugfix, a documentation update, or a new feature that has been explicitly
approved via an issue
* [ ] I ran the test suite locally (`npm run test`) and verified that it succeeded

View File

@ -1,5 +1,17 @@
# Changelog
## [1.9.10] - 2023-12-??
## [1.9.9] - 2023-11-21
* Allow CSS selectors with whitespace in attributes like `hx-target` by using parens or curly-braces
* Properly allow users to override the `Content-Type` request header
* Added the `select` option to `htmx.ajax()`
* Fixed a race condition in readystate detection that lead to htmx not being initialized in some scenarios with 3rd
party script loaders
* Fixed a bug that caused relative resources to resolve against the wrong base URL when a new URL is pushed
* Fixed a UI issue that could cause indicators to briefly flash
## [1.9.8] - 2023-11-06
* Fixed a few npm & build related issues

View File

@ -1,26 +1,31 @@
# Contributing
Thank you for your interest in contributing! Because we're a small team, we have a couple
contribution guidelines that make it easier for us to triage all the incoming suggestions.
tl;dr: if proposing a new feature, start with an issue; if you think your change is a bugfix or otherwise uncontroversial, feel free to PR, but know that we might close it and kick you back to an issue if more discussion is required.
Want to contribute but don't know where to start? Look for issues with the "help wanted" tag.
Thank you for your interest in contributing! Because we're a small team, we have a couple contribution guidelines that make it easier for us to triage all the incoming suggestions.
## Issues
1. Issues are the best place to propose a new feature. Keep in mind that htmx is a small library, so there are lots of great ideas that don't fit in the core; it's always best to check in about an idea before doing a bunch of work on it.
1. If you are adding a feature, consider doing it as an [extension](https://htmx.org/extensions). Even if we don't end up supporting it officially, you can publish it yourself and we can link to it.
1. When proposing a new features, we will often suggest that you implement it as an [extension](https://htmx.org/extensions), so try that first. Even if we don't end up supporting it officially, you can publish it yourself and we can link to it.
1. Search the issues before proposing a feature to see if it is already under discussion. Referencing existing issues is a good way to increase the priority of your own.
1. We don't have an issue template yet, but the more detailed your explanation, the more quickly we'll be able to evaluate it.
1. We don't have an issue template yet, but the more detailed your description of the issue, the more quickly we'll be able to evaluate it.
1. See an issue that you also have? Give it a reaction (and comment, if you have something to add). We note that!
1. If you haven't gotten any traction on an issue, feel free to bump it in the #issues-and-pull-requests channel on our Discord.
1. Want to contribute but don't know where to start? Look for issues with the "help wanted" tag.
## Pull Requests
1. Open PRs represent issues that we're actively thinking working on merging (at a pace we can manage). If we think a proposal needs more discussion, or that the existing code would require a lot of back-and-forth to merge, we might close it and suggest you make an issue.
1. All PRs should be made against the `dev` branch, except documentation PRs (`www/` directory) which can be made against `master`.
1. Please avoid sending the `dist` files along your PR, only include the `src` ones
### Technical Requirements
1. Code, including tests, must be written in ES5 for [IE 11 compatibility](https://stackoverflow.com/questions/39902809/support-for-es6-in-internet-explorer-11).
1. All PRs must be made against the `dev` branch, except documentation PRs (that only modify the `www/` directory) which can be made against `master`.
1. Please avoid sending the `dist` files along your PR, only include the `src` ones.
1. Please include test cases in [`/test`](https://github.com/bigskysoftware/htmx/tree/dev/test) and docs in [`/www`](https://github.com/bigskysoftware/htmx/tree/dev/www).
1. We squash all PRs, so you're welcome to submit with as many commits are you like; they will be evaluated as a single, standalone change.
### Review Guidelines
1. Open PRs represent issues that we're actively thinking working on merging (at a pace we can manage). If we think a proposal needs more discussion, or that the existing code would require a lot of back-and-forth to merge, we might close it and suggest you make an issue.
1. Smaller PRs are easier and quicker to review. If we feel that the scope of your changes is too large, we will close the PR and try to suggest ways that the change could be broken down.
1. Please do not PR new features unless you have already made an issue proposing the feature, and had it accepted by a core maintainer. This helps us triage the features we can support before you put a lot of work into them.
1. Correspondingly, it is fine to directly PR bugfixes for behavior that htmx already guarantees, but please check if there's an issue first, and if you're not sure whether this *is* a bug, make an issue where we can hash it out..
1. Refactors that do not make functional changes will be automatically closed, unless explicitly solicited. Imagine someone came into your house unannounced, rearranged a bunch of furniture, and left.
1. Typo fixes in documentation are welcome, but if it's at all debatable we might just close it.
1. Typo fixes in the documentation (not the code comments) are welcome, but formatting or debatable grammar changes will be automatically closed.
## Misc
1. If you think we closed something incorrectly, feel free to (politely) tell us why! We're human and make mistakes.
1. There are lots of ways to improve htmx besides code changes. Sometimes a problem can be solved with better docs, usage patterns, extensions, or community support. Talk to us and we can almost always help you get to a solution.

View File

@ -33,7 +33,7 @@ By removing these arbitrary constraints htmx completes HTML as a
## quick start
```html
<script src="https://unpkg.com/htmx.org@1.9.8"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- have a button POST a click via AJAX -->
<button hx-post="/clicked" hx-swap="outerHTML">
Click Me

2
dist/ext/ws.js vendored
View File

@ -200,7 +200,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
if (!this.socket) {
api.triggerErrorEvent()
}
if (sendElt && api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message: message,
socketWrapper: this.publicInterface
})) {

7
dist/htmx.d.ts vendored
View File

@ -48,7 +48,7 @@ export function ajax(verb: string, path: string, selector: string): Promise<void
export function ajax(
verb: string,
path: string,
context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any }>
context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any; select: any }>
): Promise<void>;
/**
@ -395,6 +395,11 @@ export interface HtmxConfig {
* @default false
*/
selfRequestsOnly?: boolean;
/**
* Whether or not the target of a boosted element is scrolled into the viewport.
* @default true
*/
scrollIntoViewOnBoost?: boolean;
}
/**

135
dist/htmx.js vendored
View File

@ -75,6 +75,7 @@ return (function () {
globalViewTransitions: false,
methodsThatUseUrlParams: ["get"],
selfRequestsOnly: false,
ignoreTitle: false,
scrollIntoViewOnBoost: true
},
parseInterval:parseInterval,
@ -87,7 +88,7 @@ return (function () {
sock.binaryType = htmx.config.wsBinaryType;
return sock;
},
version: "1.9.8"
version: "1.9.9"
};
/** @type {import("./htmx").HtmxInternalApi} */
@ -1145,6 +1146,8 @@ return (function () {
var SYMBOL_CONT = /[_$a-zA-Z0-9]/;
var STRINGISH_START = ['"', "'", "/"];
var NOT_WHITESPACE = /[^\s]/;
var COMBINED_SELECTOR_START = /[{(]/;
var COMBINED_SELECTOR_END = /[})]/;
function tokenizeString(str) {
var tokens = [];
var position = 0;
@ -1233,6 +1236,18 @@ return (function () {
return result;
}
function consumeCSSSelector(tokens) {
var result;
if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) {
tokens.shift();
result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim();
tokens.shift();
} else {
result = consumeUntil(tokens, WHITESPACE_OR_COMMA);
}
return result;
}
var INPUT_SELECTOR = 'input, textarea, select';
/**
@ -1281,29 +1296,33 @@ return (function () {
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
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 = consumeUntil(
tokens,
WHITESPACE_OR_COMMA
)
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
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 = consumeUntil(tokens, WHITESPACE_OR_COMMA);
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" || token === "threshold") && tokens[0] === ":") {
} 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 {
@ -2906,6 +2925,7 @@ return (function () {
values : context.values,
targetOverride: resolveTarget(context.target),
swapOverride: context.swap,
select: context.select,
returnPromise: true
});
}
@ -2960,6 +2980,7 @@ return (function () {
elt = getDocument().body;
}
var responseHandler = etc.handler || handleAjaxResponse;
var select = etc.select || null;
if (!bodyContains(elt)) {
// do not issue requests for elements removed from the DOM
@ -3108,6 +3129,11 @@ return (function () {
var headers = getHeaders(elt, target, promptResponse);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (etc.headers) {
headers = mergeObjects(headers, etc.headers);
}
@ -3121,10 +3147,6 @@ return (function () {
var allParameters = mergeObjects(rawParameters, expressionVars);
var filteredParameters = filterValues(allParameters, elt);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (htmx.config.getCacheBusterParam && verb === 'get') {
filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true";
}
@ -3222,7 +3244,7 @@ return (function () {
}
var responseInfo = {
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted,
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select,
pathInfo: {
requestPath: path,
finalRequestPath: finalPath,
@ -3393,6 +3415,7 @@ return (function () {
var target = responseInfo.target;
var etc = responseInfo.etc;
var requestConfig = responseInfo.requestConfig;
var select = responseInfo.select;
if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return;
@ -3502,10 +3525,26 @@ return (function () {
}
var selectOverride;
if (select) {
selectOverride = select;
}
if (hasHeader(xhr, /HX-Reselect:/i)) {
selectOverride = xhr.getResponseHeader("HX-Reselect");
}
// if we need to save history, do so, before swapping so that relative resources have the correct base URL
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
var settleInfo = makeSettleInfo(target);
selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride);
@ -3555,17 +3594,6 @@ return (function () {
triggerEvent(elt, 'htmx:afterSettle', responseInfo);
});
// if we need to save history, do so
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
if (responseInfo.pathInfo.anchor) {
var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor);
if(anchorTarget) {
@ -3724,25 +3752,34 @@ return (function () {
//====================================================================
// Initialization
//====================================================================
var isReady = false
getDocument().addEventListener('DOMContentLoaded', function() {
isReady = true
})
/**
* 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.
* 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(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);
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;
};
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();
});
}
}
@ -3750,9 +3787,9 @@ return (function () {
if (htmx.config.includeIndicatorStyles !== false) {
getDocument().head.insertAdjacentHTML("beforeend",
"<style>\
." + htmx.config.indicatorClass + "{opacity:0;transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.indicatorClass + "{opacity:0}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
</style>");
}
}

2
dist/htmx.min.js vendored

File diff suppressed because one or more lines are too long

BIN
dist/htmx.min.js.gz vendored

Binary file not shown.

View File

@ -5,7 +5,7 @@
"AJAX",
"HTML"
],
"version": "1.9.8",
"version": "1.9.10",
"homepage": "https://htmx.org/",
"bugs": {
"url": "https://github.com/bigskysoftware/htmx/issues"

View File

@ -200,7 +200,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
if (!this.socket) {
api.triggerErrorEvent()
}
if (sendElt && api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message: message,
socketWrapper: this.publicInterface
})) {

37
src/htmx.d.ts vendored
View File

@ -48,7 +48,7 @@ export function ajax(verb: string, path: string, selector: string): Promise<void
export function ajax(
verb: string,
path: string,
context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any }>
context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any; select: any }>
): Promise<void>;
/**
@ -395,6 +395,41 @@ export interface HtmxConfig {
* @default false
*/
selfRequestsOnly?: boolean;
/**
* Whether or not the target of a boosted element is scrolled into the viewport.
* @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;
}
/**

View File

@ -69,12 +69,13 @@ return (function () {
wsBinaryType: 'blob',
disableSelector: "[hx-disable], [data-hx-disable]",
useTemplateFragments: false,
scrollBehavior: 'smooth',
scrollBehavior: 'instant',
defaultFocusScroll: false,
getCacheBusterParam: false,
globalViewTransitions: false,
methodsThatUseUrlParams: ["get"],
selfRequestsOnly: false,
methodsThatUseUrlParams: ["get", "delete"],
selfRequestsOnly: true,
ignoreTitle: false,
scrollIntoViewOnBoost: true,
responseHandling: [
{code:"203", swap: false},
@ -92,7 +93,7 @@ return (function () {
sock.binaryType = htmx.config.wsBinaryType;
return sock;
},
version: "1.9.8"
version: "1.9.10"
};
/** @type {import("./htmx").HtmxInternalApi} */
@ -280,7 +281,7 @@ return (function () {
}
function aFullPageResponse(resp) {
return resp.match(/<body/);
return /<body/.test(resp)
}
/**
@ -454,7 +455,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;
@ -831,7 +832,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) {
@ -938,7 +939,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);
}
@ -1150,6 +1151,8 @@ return (function () {
var SYMBOL_CONT = /[_$a-zA-Z0-9]/;
var STRINGISH_START = ['"', "'", "/"];
var NOT_WHITESPACE = /[^\s]/;
var COMBINED_SELECTOR_START = /[{(]/;
var COMBINED_SELECTOR_END = /[})]/;
function tokenizeString(str) {
var tokens = [];
var position = 0;
@ -1232,12 +1235,24 @@ 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;
}
function consumeCSSSelector(tokens) {
var result;
if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) {
tokens.shift();
result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim();
tokens.shift();
} else {
result = consumeUntil(tokens, WHITESPACE_OR_COMMA);
}
return result;
}
var INPUT_SELECTOR = 'input, textarea, select';
/**
@ -1286,29 +1301,33 @@ return (function () {
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
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 = consumeUntil(
tokens,
WHITESPACE_OR_COMMA
)
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
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 = consumeUntil(tokens, WHITESPACE_OR_COMMA);
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" || token === "threshold") && tokens[0] === ":") {
} 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 {
@ -1880,26 +1899,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])
}
}
}
@ -1909,9 +1937,9 @@ 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]," +
" [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");
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]");
return results;
} else {
return [];
@ -1956,7 +1984,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++;
@ -1985,32 +2013,6 @@ return (function () {
nodeData.onHandlers.push({event:eventName, listener:listener});
}
function processHxOn(elt) {
var hxOnValue = getAttributeValue(elt, 'hx-on');
if (hxOnValue) {
var handlers = {}
var lines = hxOnValue.split("\n");
var currentEvent = null;
var curlyCount = 0;
while (lines.length > 0) {
var line = lines.shift();
var match = line.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);
if (curlyCount === 0 && match) {
line.split(":")
currentEvent = match[1].slice(0, -1); // strip last colon
handlers[currentEvent] = match[2];
} else {
handlers[currentEvent] += line;
}
curlyCount += countCurlies(line);
}
for (var eventName in handlers) {
addHxOnEventHandler(elt, eventName, handlers[eventName]);
}
}
}
function processHxOnWildcard(elt) {
// wipe any previous on handlers so that this function takes precedence
deInitOnHandlers(elt)
@ -2018,12 +2020,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)
}
}
}
}
@ -2040,8 +2052,6 @@ return (function () {
nodeData.initHash = attributeHash(elt);
processHxOn(elt);
triggerEvent(elt, "htmx:beforeProcessNode")
if (elt.value) {
@ -2090,8 +2100,6 @@ return (function () {
}
initNode(elt);
forEach(findElementsToProcess(elt), function(child) { initNode(child) });
// Because it happens second, the new way of adding onHandlers superseeds the old one
// i.e. if there are any hx-on:eventName attributes, the hx-on attribute will be ignored
forEach(findHxOnWildcardElements(elt), processHxOnWildcard);
}
@ -2301,7 +2309,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);
@ -2892,7 +2902,7 @@ return (function () {
}
function hasHeader(xhr, regexp) {
return xhr.getAllResponseHeaders().match(regexp);
return regexp.test(xhr.getAllResponseHeaders())
}
function ajaxHelper(verb, path, context) {
@ -2911,6 +2921,7 @@ return (function () {
values : context.values,
targetOverride: resolveTarget(context.target),
swapOverride: context.swap,
select: context.select,
returnPromise: true
});
}
@ -2965,6 +2976,7 @@ return (function () {
elt = getDocument().body;
}
var responseHandler = etc.handler || handleAjaxResponse;
var select = etc.select || null;
if (!bodyContains(elt)) {
// do not issue requests for elements removed from the DOM
@ -3113,6 +3125,11 @@ return (function () {
var headers = getHeaders(elt, target, promptResponse);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (etc.headers) {
headers = mergeObjects(headers, etc.headers);
}
@ -3126,10 +3143,6 @@ return (function () {
var allParameters = mergeObjects(rawParameters, expressionVars);
var filteredParameters = filterValues(allParameters, elt);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (htmx.config.getCacheBusterParam && verb === 'get') {
filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true";
}
@ -3227,7 +3240,7 @@ return (function () {
}
var responseInfo = {
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted,
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select,
pathInfo: {
requestPath: path,
finalRequestPath: finalPath,
@ -3416,6 +3429,7 @@ return (function () {
var target = responseInfo.target;
var etc = responseInfo.etc;
var requestConfig = responseInfo.requestConfig;
var select = responseInfo.select;
if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return;
@ -3538,10 +3552,27 @@ return (function () {
// safari issue - see https://github.com/microsoft/playwright/issues/5894
}
var selectOverride;
if (select) {
selectOverride = select;
}
if (hasHeader(xhr, /HX-Reselect:/i)) {
selectOverride = xhr.getResponseHeader("HX-Reselect");
}
// if we need to save history, do so, before swapping so that relative resources have the correct base URL
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
var settleInfo = makeSettleInfo(target);
selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride);
@ -3591,17 +3622,6 @@ return (function () {
triggerEvent(elt, 'htmx:afterSettle', responseInfo);
});
// if we need to save history, do so
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
if (responseInfo.pathInfo.anchor) {
var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor);
if(anchorTarget) {
@ -3760,25 +3780,34 @@ return (function () {
//====================================================================
// Initialization
//====================================================================
var isReady = false
getDocument().addEventListener('DOMContentLoaded', function() {
isReady = true
})
/**
* 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.
* 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(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);
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;
};
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();
});
}
}
@ -3786,9 +3815,9 @@ return (function () {
if (htmx.config.includeIndicatorStyles !== false) {
getDocument().head.insertAdjacentHTML("beforeend",
"<style>\
." + htmx.config.indicatorClass + "{opacity:0;transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.indicatorClass + "{opacity:0}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
</style>");
}
}
@ -3826,7 +3855,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

@ -116,4 +116,3 @@ describe("hx-boost attribute", function() {
});
});

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

@ -895,5 +895,64 @@ describe("hx-trigger attribute", function(){
form.innerHTML.should.equal("Called!");
})
it("correctly handles CSS descendant combinators", function(){
this.server.respondWith("GET", "/test", "Clicked!");
var outer = make(`
<div>
<div id='outer'>
<div id='first'>
<div id='inner'></div>
</div>
<div id='second' hx-get='/test' hx-trigger='click from:previous (#outer div)'>Unclicked.</div>
</div>
<div id='other' hx-get='/test' hx-trigger='click from:(div #inner)'>Unclicked.</div>
</div>
`);
var inner = byId("inner");
var second = byId("second");
var other = byId("other");
second.innerHTML.should.equal("Unclicked.");
other.innerHTML.should.equal("Unclicked.");
inner.click();
this.server.respond();
second.innerHTML.should.equal("Clicked!");
other.innerHTML.should.equal("Clicked!");
})
it('correctly handles CSS descendant combinators in modifier target', function() {
this.server.respondWith('GET', '/test', 'Called');
document.addEventListener('htmx:syntax:error', function(evt) {
chai.assert.fail('htmx:syntax:error');
});
make('<div class="d1"><a id="a1" class="a1">Click me</a><a id="a2" class="a2">Click me</a></div>');
var div = make('<div hx-trigger="click from:body target:(.d1 .a2)" hx-get="/test">Not Called</div>');
byId('a1').click();
this.server.respond();
div.innerHTML.should.equal("Not Called");
byId('a2').click();
this.server.respond();
div.innerHTML.should.equal("Called");
});
it('correctly handles CSS descendant combinators in modifier root', function() {
this.server.respondWith('GET', '/test', 'Called');
document.addEventListener('htmx:syntax:error', function(evt) {
chai.assert.fail('htmx:syntax:error');
});
make('<div hx-trigger="intersect root:{form input}" hx-get="/test">Not Called</div>');
});
})

View File

@ -868,9 +868,7 @@ describe("Core htmx AJAX Tests", function(){
var btn = make('<button hx-get="/test">Click Me!</button>')
btn.click();
this.server.respond();
if (supportsSvgTitles()) { // IE 11
btn.innerText.should.equal("Clicked!");
}
btn.innerText.should.equal("Clicked!");
window.document.title.should.equal(originalTitle);
});
@ -884,9 +882,7 @@ describe("Core htmx AJAX Tests", function(){
var btn = make('<button hx-get="/test">Click Me!</button>')
btn.click();
this.server.respond();
if (supportsSvgTitles()) { // IE 11
btn.innerText.should.equal("Clicked!");
}
btn.innerText.should.equal("Clicked!");
window.document.title.should.equal(newTitle);
});
@ -1089,11 +1085,6 @@ describe("Core htmx AJAX Tests", function(){
})
it('properly handles clicked submit button with a value outside a htmx form', function () {
if (!supportsFormAttribute()) {
this._runnable.title += " - Skipped as IE11 doesn't support form attribute"
this.skip()
return
}
var values;
this.server.respondWith("Post", "/test", function (xhr) {
values = getParameters(xhr);
@ -1111,11 +1102,6 @@ describe("Core htmx AJAX Tests", function(){
})
it('properly handles clicked submit input with a value outside a htmx form', function () {
if (!supportsFormAttribute()) {
this._runnable.title += " - Skipped as IE11 doesn't support form attribute"
this.skip()
return
}
var values;
this.server.respondWith("Post", "/test", function (xhr) {
values = getParameters(xhr);
@ -1187,11 +1173,6 @@ describe("Core htmx AJAX Tests", function(){
})
it('properly handles clicked submit button with a value inside a form, referencing another form', function () {
if (!supportsFormAttribute()) {
this._runnable.title += " - Skipped as IE11 doesn't support form attribute"
this.skip()
return
}
var values;
this.server.respondWith("Post", "/test", function (xhr) {
values = getParameters(xhr);
@ -1212,11 +1193,6 @@ describe("Core htmx AJAX Tests", function(){
})
it('properly handles clicked submit input with a value inside a form, referencing another form', function () {
if (!supportsFormAttribute()) {
this._runnable.title += " - Skipped as IE11 doesn't support form attribute"
this.skip()
return
}
var values;
this.server.respondWith("Post", "/test", function (xhr) {
values = getParameters(xhr);
@ -1284,6 +1260,8 @@ describe("Core htmx AJAX Tests", function(){
byId("submit").click();
this.server.respond();
responded.should.equal(true);
})
it("can associate submit buttons from outside a form with the current version of the form after swap", function(){
const template = '<form ' +
'id="hello" ' +

View File

@ -225,6 +225,24 @@ describe("Core htmx API test", function(){
div.innerHTML.should.equal('<p class="test">foo!</p>');
});
it('ajax api works with select', function()
{
this.server.respondWith("GET", "/test", "<div id='d1'>foo</div><div id='d2'>bar</div>");
var div = make("<div id='target'></div>");
htmx.ajax("GET", "/test", {target: "#target", select: "#d2"});
this.server.respond();
div.innerHTML.should.equal('<div id="d2">bar</div>');
});
it('ajax api works with Hx-Select overrides select', function()
{
this.server.respondWith("GET", "/test", [200, {"HX-Reselect": "#d2"}, "<div id='d1'>foo</div><div id='d2'>bar</div>"]);
var div = make("<div id='target'></div>");
htmx.ajax("GET", "/test", {target: "#target", select: "#d1"});
this.server.respond();
div.innerHTML.should.equal('<div id="d2">bar</div>');
});
it('ajax returns a promise', function(done)
{
// in IE we do not return a promise
@ -255,6 +273,44 @@ describe("Core htmx API test", function(){
div.innerHTML.should.equal("Clicked!");
});
it('ajax api Content-Type header is application/x-www-form-urlencoded', function(){
this.server.respondWith("POST", "/test", function (xhr) {
var params = getParameters(xhr);
xhr.requestHeaders['Content-Type'].should.equal('application/x-www-form-urlencoded;charset=utf-8');
params['i1'].should.equal("test");
xhr.respond(200, {}, "Clicked!")
});
var div = make("<div id='d1'></div>");
htmx.ajax("POST", "/test", {target:"#d1", values:{i1: 'test'}})
this.server.respond();
div.innerHTML.should.equal("Clicked!");
});
it('ajax api Content-Type header override to application/json', function(){
this.server.respondWith("POST", "/test", function (xhr) {
var params = getParameters(xhr);
xhr.requestHeaders['Content-Type'].should.equal('application/json;charset=utf-8');
params['i1'].should.equal("test");
xhr.respond(200, {}, "Clicked!");
});
var div = make("<div id='d1'></div>");
htmx.ajax('POST',"/test", {
target:'#d1',
swap:'innerHTML',
headers: {
'Content-Type': 'application/json'
},
values:{i1: 'test'}
})
this.server.respond();
div.innerHTML.should.equal("Clicked!");
});
it('can re-init with new attributes', function () {
this.server.respondWith("PATCH", "/test", "patch");
this.server.respondWith("DELETE", "/test", "delete");

View File

@ -389,12 +389,7 @@ describe("Core htmx Events", function() {
});
it("htmx:sendError is called after a failed request", function (done) {
if (IsIE11()) {
// IE will throw an exception on xhr.open with the URL below, xhr.send won't even be called
this._runnable.title += " - Skipped on IE11 as xhr.send won't even be called with a file URL"
this.skip()
return
}
htmx.config.selfRequestsOnly = false; // turn off self requests only
var called = false;
var handler = htmx.on("htmx:sendError", function (evt) {
called = true;
@ -405,6 +400,7 @@ describe("Core htmx Events", function() {
setTimeout(function () {
htmx.off("htmx:sendError", handler);
should.equal(called, true);
htmx.config.selfRequestsOnly = true; // restore self requests only
done();
}, 30);
});

View File

@ -29,11 +29,6 @@ describe('Core htmx extension tests', function() {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
evt.preventDefault();
if (IsIE11()) {
// IE11 doesn't set defaultPrevented to true on custom events it seems, so use a
// return false instead to cancel the event
return false
}
}
}
});

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

@ -22,11 +22,6 @@ describe("Core htmx internals Tests", function() {
})
it("makeFragment works with template wrapping", function(){
if (!supportsTemplates()) {
this._runnable.title += " - Skipped as IE11 doesn't support templates"
this.skip()
return
}
try {
htmx._("makeFragment")("<html></html>").children.length.should.equal(0);
htmx._("makeFragment")("<html><body></body></html>").children.length.should.equal(0);
@ -50,11 +45,6 @@ describe("Core htmx internals Tests", function() {
it("makeFragment works with template wrapping and funky combos", function(){
if (!supportsTemplates()) {
this._runnable.title += " - Skipped as IE11 doesn't support templates"
this.skip()
return
}
htmx.config.useTemplateFragments = true;
try {
var fragment = htmx._("makeFragment")("<td></td><div></div>");

View File

@ -50,10 +50,6 @@ describe("Core htmx perf Tests", function() {
it("history snapshot cleaning should be fast", function(){
var size = 5 * 1024 // ~350K in size, about the size of CNN's body tag :p
if (IsIE11()) {
// So slow in IE11 it freezes the browser and blocks other tests, pretty annoying
size = 5 * 100 // Seriously this already takes ~1.5 SECOND to run, more simply makes it crash
}
var workArea = getWorkArea();
var html = "<div class='foo bar'>Yay, really large HTML documents are fun!</div>\n";
html = stringRepeat(html, size);

View File

@ -129,12 +129,6 @@ describe("Core htmx Regression Tests", function(){
})
it('a form can reset based on the htmx:afterRequest event', function() {
if (IsIE11()) {
this._runnable.title += " - Skipped as hyperscript isn't IE11 compatible"
this.skip()
return
}
this.server.respondWith("POST", "/test", "posted");
var form = make('<div id="d1"></div><form _="on htmx:afterRequest reset() me" hx-post="/test" hx-target="#d1">' +

View File

@ -106,10 +106,12 @@ describe("security options", function() {
btn.innerHTML.should.equal("Clicked a second time");
})
it("can make egress cross site requests when htmx.config.selfRequestsOnly is enabled", function(done){
it("can make egress cross site requests when htmx.config.selfRequestsOnly is disabled", function(done){
this.timeout(4000)
htmx.config.selfRequestsOnly = false;
// should trigger send error, rather than reject
var listener = htmx.on("htmx:sendError", function (){
htmx.config.selfRequestsOnly = true;
htmx.off("htmx:sendError", listener);
done();
});
@ -122,9 +124,7 @@ describe("security options", function() {
it("can't make egress cross site requests when htmx.config.selfRequestsOnly is enabled", function(done){
this.timeout(4000)
// should trigger send error, rather than reject
htmx.config.selfRequestsOnly = true;
var listener = htmx.on("htmx:invalidPath", function (){
htmx.config.selfRequestsOnly = false;
htmx.off("htmx:invalidPath", listener);
done();
})

View File

@ -110,12 +110,6 @@ describe("Core htmx client side validation tests", function(){
it('hyperscript validation error prevents request', function()
{
if (IsIE11()) {
this._runnable.title += " - Skipped as hyperscript isn't IE11 compatible"
this.skip()
return
}
this.server.respondWith("POST", "/test", "Clicked!");
var form = make('<form hx-post="/test" hx-trigger="click">' +

View File

@ -2,11 +2,6 @@ describe("hyperscript integration", function() {
beforeEach(function () {
this.server = makeServer();
clearWorkArea();
if (IsIE11()) {
this.title += " - Skipped as hyperscript isn't IE11 compatible"
this.skip()
}
});
afterEach(function () {
this.server.restore();

View File

@ -550,12 +550,6 @@ describe("web-sockets extension", function () {
})
it('sends data to the server with external non-htmx form + submit button & value', function () {
if (!supportsFormAttribute()) {
this._runnable.title += " - Skipped as IE11 doesn't support form attribute"
this.skip()
return
}
make('<div hx-ext="ws" ws-connect="ws://localhost:8080">' +
'<form ws-send id="form">' +
'<input type="hidden" name="foo" value="bar">' +
@ -583,12 +577,6 @@ describe("web-sockets extension", function () {
})
it('sends data to the server with external non-htmx form + submit input & value', function () {
if (!supportsFormAttribute()) {
this._runnable.title += " - Skipped as IE11 doesn't support form attribute"
this.skip()
return
}
make('<div hx-ext="ws" ws-connect="ws://localhost:8080">' +
'<form ws-send id="form">' +
'<input type="hidden" name="foo" value="bar">' +
@ -614,4 +602,205 @@ describe("web-sockets extension", function () {
this.messages[1].should.contains('"foo":"bar"')
this.messages[1].should.contains('"action":"B"')
})
describe("Send immediately", function() {
function checkCallForWsBeforeSend(spy, wrapper, message, target) {
// Utility function to always check the same for htmx:wsBeforeSend caught by a spy
spy.calledOnce.should.be.true;
var call = spy.getCall(0);
call.args.length.should.equal(1);
var arg = call.args[0];
arg.target.should.equal(target);
arg.detail.socketWrapper.should.equal(wrapper);
arg.detail.message.should.equal(message);
}
it('triggers wsBeforeSend on body if provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"foo":"bar"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
window.document.body.addEventListener("htmx:wsBeforeSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, window.document.body)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body)
} finally {
window.document.body.removeEventListener("htmx:wsBeforeSend", myEventCalled)
}
done()
}
try {
window.document.addEventListener("htmx:wsOpen", handler)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('triggers wsBeforeSend on any send element provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsBeforeSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, d1)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1)
} finally {
id1.removeEventListener("htmx:wsBeforeSend", myEventCalled)
}
done()
}
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('triggers wsAfterSend on body if provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"foo":"bar"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
window.document.body.addEventListener("htmx:wsAfterSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, window.document.body)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body)
} finally {
window.document.body.removeEventListener("htmx:wsAfterSend", myEventCalled)
}
done()
}
try {
window.document.addEventListener("htmx:wsOpen", handler)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('triggers wsAfterSend on any send element provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsAfterSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, d1)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1)
} finally {
id1.removeEventListener("htmx:wsAfterSend", myEventCalled)
}
done()
}
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('sends message if event is not prevented', function (done) {
var message = '{"a":"b"}';
var noop = function() {}
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsBeforeSend", noop)
try {
socketWrapper.sendImmediately(message, d1)
this.tickMock();
this.messages.should.eql([message])
} finally {
id1.removeEventListener("htmx:wsBeforeSend", noop)
}
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('sends message if no sending element is provided', function (done) {
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
socketWrapper.sendImmediately(message)
this.tickMock();
this.messages.should.eql([message])
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('sends message if sending element has no event listener for beforeSend', function (done) {
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var d1 = byId("d1");
socketWrapper.sendImmediately(message, d1)
this.tickMock();
this.messages.should.eql([message])
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('does not send message if beforeSend is prevented', function (done) {
var message = '{"a":"b"}';
var eventPrevented = function(e) {e.preventDefault()}
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsBeforeSend", eventPrevented)
try {
socketWrapper.sendImmediately(message, d1)
this.tickMock();
this.messages.should.eql([])
} finally {
id1.removeEventListener("htmx:wsBeforeSend", eventPrevented)
}
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
})
});

View File

@ -81,7 +81,6 @@
<script src="attributes/hx-indicator.js"></script>
<script src="attributes/hx-disabled-elt.js"></script>
<script src="attributes/hx-disinherit.js"></script>
<script src="attributes/hx-on.js"></script>
<script src="attributes/hx-on-wildcard.js"></script>
<script src="attributes/hx-params.js"></script>
<script src="attributes/hx-patch.js"></script>
@ -169,7 +168,6 @@
<script class="mocha-exec">
document.addEventListener("DOMContentLoaded", function () {
mocha.setup({globals: ['$0', '$1', '$2', '$3', '$4', 'performance', 'requestAnimationFrame', 'cancelAnimationFrame', 'confirm']}); <!-- IE11 -->
mocha.run();
})
</script>

View File

@ -0,0 +1,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<script type="application/javascript" src="../../../src/htmx.js"></script>
<title>hx-boost - Relative Resources Page 1</title>
</head>
<body style="padding:20px;font-family: sans-serif" hx-boost="true">
<a href="nested/page2.html">To Page 2</a>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,12 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<script type="application/javascript" src="../../../../src/htmx.js"></script>
<title>hx-boost - Relative Resources Page 2</title>
</head>
<body style="padding:20px;font-family: sans-serif" hx-boost="true">
<a href="../index.html">Back To Page 1</a>
<p>Image should be displayed below</p>
<img src="img.png" />
</body>
</html>

View File

@ -37,6 +37,12 @@
<li><a href="history_style">History Style</a></li>
</ul>
</li>
<li>Boost Tests
<ul>
<li><a href="hxboost_relative_resources">Relative Resources</a></li>
<li><a href="hxboost_template_parsing">Template Parsing</a></li>
</ul>
</li>
</ul>
<h2>Perf</h2>
<li><a href="manual-perf.html">Manual Perf Test</a></li>

View File

@ -1 +1,5 @@
foo
<html>
<body>
<div id="foo" fooBar="10">asdfasdf</div>
</body>
</html>

View File

@ -112,33 +112,3 @@ function log(val) {
console.log(val);
return val;
}
// region IE11
function supportsTemplates() {
return typeof document.createElement("template").content !== "undefined"
}
function supportsSvgTitles() {
// Need to append the element to the body, otherwise innerText will add the svg title to the returned value...
var tempButton = document.createElement("button")
tempButton.innerHTML = '<svg><title>Svg title</title></svg>Text';
document.body.appendChild(tempButton)
var titleOk = tempButton.innerText === "Text"
document.body.removeChild(tempButton)
return titleOk
}
function supportsFormAttribute() {
var parser = new DOMParser()
return !!parser.parseFromString('<button form="form"></button><form id="form"></form>', "text/html").body.firstChild.form
}
function supportsXPath() {
return typeof document.evaluate !== "undefined"
}
function IsIE11() {
return !supportsTemplates() && !supportsSvgTitles() && !supportsFormAttribute() && !supportsXPath()
}
// endregion

View File

@ -35,7 +35,7 @@ By removing these arbitrary constraints, htmx completes HTML as a [hypertext](ht
<h2>quick start</h2>
```html
<script src="https://unpkg.com/htmx.org@1.9.8"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- have a button POST a click via AJAX -->
<button hx-post="/clicked" hx-swap="outerHTML">
Click Me
@ -72,6 +72,7 @@ Thank you to all our generous <a href="https://github.com/sponsors/bigskysoftwar
#sponsor-table td {
text-align: center;
padding: 16px;
min-height: 100px;
}
@media only screen and (max-width: 760px) {
@ -91,25 +92,27 @@ Thank you to all our generous <a href="https://github.com/sponsors/bigskysoftwar
<a href="https://www.jetbrains.com//"><img src="/img/jetbrains.png" style="max-width:30%;min-width:200px;"></a>
</td>
<td>
<a href="https://www.nuclei.ai/"><img src="/img/nuclei_logo_with_text.svg" style="max-width:50%;min-width:200px;"></a>
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next//"><img src="/img/Github_Logo.png" style="max-width:30%;min-width:200px;"></a>
</td>
<tr>
<td>
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next//"><img src="/img/Github_Logo.png" style="max-width:30%;min-width:200px;"></a>
<a href="https://www.commspace.co.za/"><img src="/img/commspace.svg" style="width:100%;max-width:400px"></a>
</td>
<td>
<a href="https://www.commspace.co.za/"><img src="/img/commspace.svg" style="width:100%;max-width:600px"></a>
<a href="https://craftcms.com"><img src="/img/logo-craft-cms.svg" style="width:90%;max-width:200px"></a>
</td>
</tr>
<tr>
<td>
<a href="https://craftcms.com"><img src="/img/logo-craft-cms.svg" style="width:90%;max-width:200px"></a>
</td>
<td>
<a href="https://buttercms.com/?utm_campaign=sponsorship&utm_medium=banner&utm_source=htmxhome">
<img src="/img/butter-cms.svg" style="width:100%;max-width:200px">
</a>
</td>
<td>
<a href="https://black.host/">
<img src="/img/blackhost-logo.svg" style="width:100%;max-width:200px">
</a>
</td>
</tr>
<tr>
<td>

View File

@ -60,6 +60,7 @@ or
* `swap` - how the response will be swapped in relative to the target
* `values` - values to submit with the request
* `headers` - headers to submit with the request
* `select` - allows you to select the content you want swapped from a response
##### Example
@ -104,9 +105,10 @@ Note that using a [meta tag](@/docs.md#config) is the preferred mechanism for se
##### Properties
* `attributesToSettle:["class", "style", "width", "height"]` - array of strings: the attributes to settle during the settling phase
* `refreshOnHistoryMiss:false` - boolean: if set to `true` htmx will issue a full page refresh on history misses rather than use an AJAX request
* `defaultSettleDelay:20` - int: the default delay between completing the content swap and settling attributes
* `defaultSwapDelay:0` - int: the default delay between receiving a response from the server and doing the swap
* `defaultSwapStyle:'innerHtml'` - string: the default swap style to use if [`hx-swap`](@/attributes/hx-swap.md) is omitted
* `defaultSwapStyle:'innerHTML'` - string: the default swap style to use if [`hx-swap`](@/attributes/hx-swap.md) is omitted
* `historyCacheSize:10` - int: the number of pages to keep in `localStorage` for history support
* `historyEnabled:true` - boolean: whether or not to use history
* `includeIndicatorStyles:true` - boolean: if true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the `htmx-indicator` class is present
@ -117,10 +119,20 @@ Note that using a [meta tag](@/docs.md#config) is the preferred mechanism for se
* `swappingClass:'htmx-swapping'` - string: the class to place on target elements when htmx is in the swapping phase
* `allowEval:true` - boolean: allows the use of eval-like functionality in htmx, to enable `hx-vars`, trigger conditions & script tag evaluation. Can be set to `false` for CSP compatibility.
* `allowScriptTags:true` - boolean: allows script tags to be evaluated in new content
* `inlineScriptNonce:''` - string: the [nonce](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/nonce) to add to inline scripts
* `useTemplateFragments:false` - boolean: use HTML template tags for parsing content from the server. This allows you to use Out of Band content when returning things like table rows, but it is *not* IE11 compatible.
* `withCredentials:false` - boolean: allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates
* `wsReconnectDelay:full-jitter` - string/function: the default implementation of `getWebSocketReconnectDelay` for reconnecting after unexpected connection loss by the event code `Abnormal Closure`, `Service Restart` or `Try Again Later`
* `scrollBehavior:smooth` - string: the behavior for a boosted link on page transitions. The allowed values are `auto` and `smooth`. Smooth will smoothscroll to the top of the page while auto will behave like a vanilla link.
* `timeout:0` - int: the number of milliseconds a request can take before automatically being terminated
* `wsReconnectDelay:'full-jitter'` - string/function: the default implementation of `getWebSocketReconnectDelay` for reconnecting after unexpected connection loss by the event code `Abnormal Closure`, `Service Restart` or `Try Again Later`
* `wsBinaryType:'blob'` - string: the [the type of binary data](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) being received over the WebSocket connection
* `disableSelector:"[hx-disable], [data-hx-disable]"` - array of strings: htmx will not process elements with this attribute on it or a parent
* `scrollBehavior:'smooth'` - string: the behavior for a boosted link on page transitions. The allowed values are `auto` and `smooth`. Smooth will smoothscroll to the top of the page while auto will behave like a vanilla link.
* `defaultFocusScroll:false` - boolean: if the focused element should be scrolled into view, can be overridden using the [focus-scroll](@/attributes/hx-swap.md#focus-scroll) swap modifier
* `getCacheBusterParam:false` - boolean: if set to true htmx will include a cache-busting parameter in `GET` requests to avoid caching partial responses by the browser
* `globalViewTransitions:false` - boolean: if set to `true`, htmx will use the [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) API when swapping in new content.
* `methodsThatUseUrlParams:["get"]` - array of strings: htmx will format requests with these methods by encoding their parameters in the URL, not the request body
* `selfRequestsOnly:false` - boolean: if set to `true` will only allow AJAX requests to the same domain as the current document
* `ignoreTitle:false` - boolean: if set to `true` htmx will not update the title of the document when a `title` tag is found in new content
* `scrollIntoViewOnBoost:true` - boolean: whether or not the target of a boosted element is scrolled into the viewport. If `hx-target` is omitted on a boosted element, the target defaults to `body`, causing the page to scroll to the top.
##### Example

View File

@ -2,26 +2,28 @@
title = "hx-on"
+++
The `hx-on` attribute allows you to embed scripts inline to respond to events directly on an element; similar to the [`onevent` properties](https://developer.mozilla.org/en-US/docs/Web/Events/Event_handlers#using_onevent_properties) found in HTML, such as `onClick`.
The `hx-on*` attributes allow you to embed scripts inline to respond to events directly on an element; similar to the
[`onevent` properties](https://developer.mozilla.org/en-US/docs/Web/Events/Event_handlers#using_onevent_properties) found in HTML, such as `onClick`.
`hx-on` improves upon `onevent` by enabling the handling of any event for enhanced [Locality of Behaviour (LoB)](/essays/locality-of-behaviour/). This also enables you to handle any htmx event.
The `hx-on*` attributes improve upon `onevent` by enabling the handling of any arbitrary JavaScript event,
for enhanced [Locality of Behaviour (LoB)](/essays/locality-of-behaviour/) even when dealing with non-standard DOM events. For example, these
attributes allow you to handle [htmx events](/reference#events).
There are two forms of this attribute, one in which you specify the event as part of the attribute name
after a colon (`hx-on:click`, for example), and a deprecated form that uses the `hx-on` attribute directly. The
latter should only be used if IE11 support is required.
### hx-on:* (recommended)
The event name follows a colon `:` in the attribute, and the attribute value is the script to be executed:
With `hx-on` attributes, you specify the event name as part of the attribute name, after a colon. So, for example, if
you want to respond to a `click` event, you would use the attribute `hx-on:click`:
```html
<div hx-on:click="alert('Clicked!')">Click</div>
```
All htmx events can be captured, too! Make sure to use the [kebab-case event name](@/docs.md#events),
because DOM attributes do not preserve casing. For instance, `hx-on::beforeRequest` **will not work:**
use `hx-on::before-request` instead.
Note that this syntax can be used to capture all htmx events, as well as most other custom events, in addition to the
standard DOM events.
To make writing these a little easier, you can use the shorthand double-colon `hx-on::` for htmx
One gotcha to note is that DOM attributes do not preserve case. This means, unfortunately, an attribute like
`hx-on:htmx:beforeRequest` **will not work**, because the DOM lowercases the attribute names. Fortunately, htmx supports
both camel case event names and also [kebab-case event names](@/docs.md#events), so you can use `hx-on:htmx:before-request` instead.
In order to make writing htmx-based event handlers a little easier, you can use the shorthand double-colon `hx-on::` for htmx
events, and omit the "htmx" part:
```html
@ -36,7 +38,7 @@ events, and omit the "htmx" part:
```
Adding multiple handlers is easy, you just specify additional attributes:
If you wish to handle multiple different events, you can simply add multiple attributes to an element:
```html
<button hx-get="/info"
hx-on::before-request="alert('Making a request!')"
@ -45,6 +47,20 @@ Adding multiple handlers is easy, you just specify additional attributes:
</button>
```
Finally, in order to make this feature compatible with some templating languages (e.g. [JSX](https://react.dev/learn/writing-markup-with-jsx)) that do not like having a colon (`:`)
in HTML attributes, you may use dashes in the place of colons for both the long form and the shorthand form:
```html
<!-- These two are equivalent -->
<button hx-get="/info" hx-on-htmx-before-request="alert('Making a request!')">
Get Info!
</button>
<button hx-get="/info" hx-on--before-request="alert('Making a request!')">
Get Info!
</button>
```
### hx-on (deprecated)
The value is an event name, followed by a colon `:`, followed by the script:

View File

@ -153,3 +153,4 @@ The AJAX request can be triggered via JavaScript [`htmx.trigger()`](@/api.md#tri
* `hx-trigger` is not inherited
* `hx-trigger` can be used without an AJAX request, in which case it will only fire the `htmx:trigger` event
* In order to pass a CSS selector that contains whitespace (e.g. `form input`) to the `from`- or `target`-modifier, surround the selector in parentheses or curly brackets (e.g. `from:(form input)` or `from:nearest (form input)`)

View File

@ -114,7 +114,7 @@ The fastest way to get going with htmx is to load it via a CDN. You can simply a
and get going:
```html
<script src="https://unpkg.com/htmx.org@1.9.8" integrity="sha384-EAzY246d6BpbWR7sQ8+WEm40J8c3dHFsqC58IgPlh4kMbRRI6P6WA+LA/qGAyAu8" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-TODO" crossorigin="anonymous"></script>
```
While the CDN approach is extremely simple, you may want to consider [not using CDNs in production](https://blog.wesleyac.com/posts/why-not-javascript-cdn).
@ -171,6 +171,43 @@ window.htmx = require('htmx.org');
* Finally, rebuild your bundle
### htmx 1.x to 2.x Upgrade Guide
To upgrade to htmx 2.0 from htmx 1.0, you will need to do the following:
* If you are still using the legacy `hx-ws` and `hx-sse` attributes, please upgrade to the extension versions (available in 1.x)
of those features
* Default Changes
* If you want to retain the 1.0 behavior of "smooth scrolling" by default, revert `htmx.config.scrollBehavior` to `'smooth'`
* If you want `DELETE` requests to use a form-encoded body rather than parameters, revert
`htmx.config.methodsThatUseUrlParams` to `["get"]` (it's a little crazy, but `DELETE`, according to the spec, should
use request parameters.)
* If you want to make cross-domain requests with htmx, revert `htmx.config.selfRequestsOnly` to `false`
* Convert any `hx-on` attributes to their `hx-on:` equivalent:
```html
<button hx-get="/info" hx-on="htmx:beforeRequest: alert('Making a request!')
htmx:afterRequest: alert('Done making a request!')">
Get Info!
</button>
```
becomes:
```html
<button hx-get="/info" hx-on:htmx:before-request="alert('Making a request!')"
hx-on:htmx:after-request="alert('Done making a request!')">
Get Info!
</button>
Note that you must use the kebab-case of the event name due to the fact that attributes are case-insensitive in HTML.
```
here is a meta tag to revert to htmx 1.x defaults:
```html
<meta name="htmx-config" content='{"scrollBehavior":"smooth", "methodsThatUseUrlParams":["get"], "selfRequestsOnly": false}'>
```
IE is no longer supported in htmx 2.0, but htmx 1.x continues to support IE and will be supported for the foreseeable
future.
## AJAX
The core of htmx is a set of attributes that allow you to issue AJAX requests directly from HTML:
@ -1258,7 +1295,7 @@ Scripting solutions that pair well with htmx include:
team that created htmx. It is designed to embed well in HTML and both respond to and create events, and pairs very well
with htmx.
### <a name="hx-on"></a>[The `hx-on` Attribute](#hyperscript)
### <a name="hx-on"></a>[The `hx-on*` Attributes](#hx-on)
HTML allows the embedding of inline scripts via the [`onevent` properties](https://developer.mozilla.org/en-US/docs/Web/Events/Event_handlers#using_onevent_properties),
such as `onClick`:
@ -1271,35 +1308,45 @@ such as `onClick`:
This feature allows scripting logic to be co-located with the HTML elements the logic applies to, giving good
[Locality of Behaviour (LoB)](/essays/locality-of-behaviour). Unfortunately, HTML only allows `on*` attributes for a fixed
number of specific DOM events (e.g. `onclick`) and doesn't offer a way to respond generally to events in this embedded
manner.
number of [specific DOM events](https://www.w3schools.com/tags/ref_eventattributes.asp) (e.g. `onclick`) and
doesn't provide a generalized mechanism for responding to arbitrary events on elements.
In order to address this shortcoming, htmx offers the [`hx-on`](/attributes/hx-on) attribute. This attribute allows
you to respond to any event in a manner that preserves the LoB of the `on*` properties:
In order to address this shortcoming, htmx offers [`hx-on*`](/attributes/hx-on) attributes. These attributes allow
you to respond to any event in a manner that preserves the LoB of the standard `on*` properties.
If we wanted to respond to the `click` event using an `hx-on` attribute, we would write this:
```html
<button hx-on="click: alert('You clicked me!')">
<button hx-on:click="alert('You clicked me!')">
Click Me!
</button>
```
For a `click` event, we would recommend sticking with the standard `onclick` attribute. However, consider an htmx-powered
button that wishes to add an attribute to a request using the `htmx:configRequest` event. This would not be possible
with an `on*` property, but can be done using the `hx-on` attribute:
So, the string `hx-on`, followed by a colon (or a dahs), then by the name of the event.
For a `click` event, of course, we would recommend sticking with the standard `onclick` attribute. However, consider an
htmx-powered button that wishes to add a parameter to a request using the `htmx:config-request` event. This would not
be possible using a standard `on*` property, but it can be done using the `hx-on:htmx:config-request` attribute:
```html
<button hx-post="/example"
hx-on="htmx:configRequest: event.detail.parameters.example = 'Hello Scripting!'">
hx-on:htmx:config-request=": event.detail.parameters.example = 'Hello Scripting!'">
Post Me!
</button>
```
Here the `example` parameter is added to the `POST` request before it is issued, with the value 'Hello Scripting!'.
The `hx-on` attribute is a very simple mechanism for generalized embedded scripting. It is _not_ a replacement for more
The `hx-on*` attributes are a very simple mechanism for generalized embedded scripting. It is _not_ a replacement for more
fully developed front-end scripting solutions such as AlpineJS or hyperscript. It can, however, augment a VanillaJS-based
approach to scripting in your htmx-powered application.
Note that HTML attributes are *case insensitive*. This means that, unfortunately, events that rely on capitalization/
camel casing, cannot be responded to. If you need to support camel case events we recommend using a more fully
functional scripting solution such as AlpineJS or hyperscript. htmx dispatches all its events in both camelCase and in
kebab-case for this very reason.
### hyperscript
Hyperscript is an experimental front end scripting language designed to be expressive and easily embeddable directly in HTML

View File

@ -22,6 +22,7 @@ page_template = "essay.html"
* [A Response To "Have SPAs Ruined The Web"](@/essays/a-response-to-rich-harris.md)
* [When To Use Hypermedia?](@/essays/when-to-use-hypermedia.md)
* [The API Churn/Security Trade-off](https://intercoolerjs.org/2016/02/17/api-churn-vs-security.html)
* [Does Hypermedia Scale?](@/essays/does-hypermedia-scale.md)
* [SPA Alternative](@/essays/spa-alternative.md)
### Building Hypermedia Applications
@ -30,6 +31,7 @@ page_template = "essay.html"
* [Hypermedia-Driven Applications (HDAs)](@/essays/hypermedia-driven-applications.md)
* [Hypermedia Friendly Scripting](@/essays/hypermedia-friendly-scripting.md)
* [10 Tips For Building SSR/HDA applications](@/essays/10-tips-for-SSR-HDA-apps.md)
* [Why I Tend Not To Use Content Negotiation](@/essays/why-tend-not-to-use-content-negotiation.md)
* [Template Fragments](@/essays/template-fragments.md)
* [View Transitions](@/essays/view-transitions.md)

View File

@ -12,7 +12,7 @@ One objection that we sometimes hear to htmx and hypermedia is some variation of
> Well, it might work well for something small, but it won't scale.
It is always dangerous to provoke us with essay-fodder and so lets dig into this claim a bit and see if we can
shed some light on whether [Hypermedia-Driven Applications]((@/essays/hypermedia-driven-applications.md)) (HDAs) can scale.
shed some light on whether [Hypermedia-Driven Applications](@/essays/hypermedia-driven-applications.md) (HDAs) can scale.
## Scaling

View File

@ -24,7 +24,7 @@ Hypermedia APIs:
Data APIs, on the other hand:
* Will not benefit dramatically from REST-fulness, beyond perhaps [Level 2 of the Richardson Maturity Model](https://en.wikipedia.org/wiki/Richardson_Maturity_Model)
* Should strive for both regularity and expressivity due to the arbitrary data needs of consumers
* Should strive for both regularity and expressiveness due to the arbitrary data needs of consumers
* Should be versioned and should be very stable within a particular version of the API
* Should be consumed by code, processed and then potentially presented to a human

View File

@ -143,6 +143,7 @@ Here are some known implementations of the fragment concept:
* PHP
* [Latte](https://latte.nette.org/en/template-inheritance#toc-blocks) - Use the 3rd parameter to only render 1 block from the template - `$Latte_Engine->render('path/to/template.latte', [ 'foo' => 'bar' ], 'content');`
* [Laravel Blade](https://laravel.com/docs/10.x/blade#rendering-blade-fragments) - includes built-in support for template fragments as of v9.x
* [Twig](https://twig.symfony.com/doc/3.x/api.html#rendering-templates) - `$template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']);`
* Python
* [Django Render Block Extension](https://pypi.org/project/django-render-block/) - see [example code for htmx](https://github.com/spookylukey/django-htmx-patterns/blob/master/inline_partials.rst)
* [jinja2-fragments package](https://github.com/sponsfreixes/jinja2-fragments)

View File

@ -0,0 +1,177 @@
+++
title = "Why I Tend Not To Use Content Negotiation"
date = 2023-11-18
updated = 2023-11-18
[taxonomies]
author = ["Carson Gross"]
tag = ["posts"]
+++
I have written a lot about Hypermedia APIs vs. Data (JSON) APIs, including [the differences between the two](@/essays/hypermedia-apis-vs-data-apis.md),
what [REST "really" means](@/essays/how-did-rest-come-to-mean-the-opposite-of-rest.md) and why [HATEOAS](@/essays/hateoas.md)
isn't so bad as long as your API is interacting with a [Hypermedia Client](@/essays/hypermedia-clients.md).
Often when I am engaged in discussions with people coming from the "REST is JSON over HTTP" world (that is, the normal
world) I have to navigate a lot of language and conceptual issues:
* No, I am not advocating you return HTML as a general purpose API, hypermedia makes for a bad general purpose API
* Yes, I am advocating [tightly coupling](@/essays/two-approaches-to-decoupling.md) your web application to your hypermedia API
* No, I do not think that we will ever fix how the industry [uses the term REST](@/essays/how-did-rest-come-to-mean-the-opposite-of-rest.md)
* Yes, I am advocating you [split your data API and your hypermedia API up](@/essays/splitting-your-apis.md)
The last point often strikes people who are used to a single, general purpose JSON API as dumb: why have two APIs when you
can have a single API that can satisfy any number of types of clients? I tried to answer that question as best I can in the essay
above, but it is certainly a reasonable one to ask.
It seems like (and it is) extra work in some ways when compared to having one general API.
At this point in a conversation, someone who agrees broadly with my take on REST, [Hypermedia-Driven Applications](@/essays/hypermedia-driven-applications.md),
etc. will often jump in and say something like
> "Oh, it's easy, you just use _content negotiation_, it's baked into HTTP!"
Not being content with alienating only the general purpose JSON API enthusiasts, let me now proceed to also alienate
my erstwhile hypermedia enthusiast allies by saying:
*I don't think content negotiation is typically the right approach to
returning both JSON and HTML for most applications.*
## What Is Content Negotiation?
First things first, what is "content negotiation"?
[Content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) is a feature of HTTP that
allows a client to negotiate the content type of the response from a server. A full treatment of the implementation
in HTTP is beyond the scope of this essay, but let us consider the most well known mechanism for content negotiation
in HTTP, the [`Accept` Request Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation#the_accept_header).
The `Accept` request header allows a client, such as a browser, to indicate the `MIME` types that it is willing to accept
from the server in a response.
An example value of this header is:
```http request
Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8
```
This `Accept` header tells the server what formats the client is willing to accept. Preferences are expressed via the
`q` weighting factor. Wildcards are expressed with asterisks `*`.
In this case, the client is saying:
> I would most like to receive text/html, application/xhtml+xml or image/webp. Next I would prefer application/xml. Finally, I will accept whatever you give me.
The server then can take this information and determine the best content type to provide to the client.
This is the act of "content negotiation" and it is certainly an interesting feature of HTTP.
## Using Content Negotiation In APIs
As far as I am aware, it was the [Ruby On Rails](https://rubyonrails.org/) community that first went in in a big way
using content negotiation to provide both HTML and JSON (and other) formats from the same URL.
In Rails, this is accomplished via the [`respond_to`](https://apidock.com/rails/ActionController/MimeResponds/respond_to) helper method available in
controllers.
Leaving the gory details of Rails aside, you might have a request like an HTTP `GET` to `/contacts` that ends up invoking
a function in a `ContactsController` class that looks like this:
```ruby
def index
@contacts = Contacts.all
respond_to do |format|
format.html # default rendering logic
format.json { render json: @contacts }
end
end
```
By making use of the `respond_to` helper method, if a client makes a request with the `Accept` header above, the controller
will render an HTML response using the Rails templating systems.
However, if the `Accept` header from the client has the value `application/json` instead, Rails will render the contacts
as a JSON array for the client.
A pretty neat trick: you can keep all your controller logic, like looking up the contacts, the same and just use a
bit of ruby/Rails magic to render two different response types using content negotiation. Barely any additional work on
top of the normal Model/View/Controller logic.
You can see why people like the idea!
## So What's The Problem?
So why don't I think this is a good approach to splitting your JSON and HTML APIs up?
It boils down to the [differences between JSON APIs and Hypermedia (HTML) APIs](hypermedia-apis-vs-data-apis.md) I hinted
at earlier. In particular:
* Data APIs should be versioned and should be very stable within a particular version of the API
* Data APIs should strive for both regularity and expressiveness due to the arbitrary data needs of consumers
* Data APIs typically use some sort of token-based authentication
* Data APIs should be rate limited
* Hypermedia APIs typically use some sort of session-cookie based authentication
* Hypermedia APIs should be driven by the needs of the underlying hypermedia application
While all of these differences matter and have an effect on your controller code, pulling it in two different directions,
it is really the first and last items that make me often choose not to use content negotiation in my applications.
Your JSON API needs to be a stable set of endpoint that client code can rely on.
Your hypermedia API, on the other hand, can change dramatically based on the user interface needs of your applications.
These two things don't mix well.
To give you a concrete example, consider an end point that renders a detail view of a contact, at, say `/contacts/:id`
(where `:id` is a parameter containing the id of the contact to render). Let's say that this page has a "related contacts"
section of the UI and, further, computing these related contacts is expensive for some reason.
In this situation you might choose to use the [Lazy Loading](https://htmx.org/examples/lazy-load/) pattern to defer
loading the related contacts until after the initial contact detail screen has been rendered. This improves perceived
performance of the page for your users.
If you did this, you might put the lazy loaded content at the end-point `/contacts/:id/related`.
Now, later on, maybe you are able to optimize the computation of related contacts. At this point you might choose to
rip the `/contacts/:id/related` end-point out and just render the related contacts information in the initial page render.
All of this is fine for your hypermedia API: hypermedia, through [the uniform interface & HATEOAS](@/essays/hateoas.md)
is _designed_ to handle these sorts of changes.
However, your JSON API... not so much.
Your JSON API should remain stable. You can't be adding and removing end-points
willy-nilly. Yes, you can have _some_ end-points respond with either JSON or HTML and others only respond with HTML, but
it gets messy. What if you accidentally copy-and-paste in the wrong code somewhere, for example.
Taking all of this into account, as well as things like rate-limiting and so on, I think you can make a strong argument
that there should be a [Separation Of Concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) between the JSON
API and the hypermedia API.
(Yes, I am aware of the irony that the person who coined the term [Locality of Behaviour](@/essays/locality-of-behaviour.md)
is making a SoC argument.)
## So What's The Alternative?
The alternative is to, as I advocate in [Splitting Your APIs](@/essays/splitting-your-apis.md), erm, splitting your
APIs. This means providing different paths (or sub-domains, or whatever) for your JSON API and your hypermedia (HTML)
API.
Going back to our contacts API, we might have the following:
* The JSON API to get all contacts is found at `/api/v1/contacts`
* The Hypermedia API to get all contacts is found at `/contacts`
This layout implies two different controllers and, I say, that's a good thing: the JSON API controller can implement the
requirements of a JSON API: rate limiting, stability, maybe an expressive query mechanism like GraphQL.
Meanwhile, your
hypermedia API (really, just your Hypermedia Driven Application endpoints) can change dramatically as your user interface
needs change, with highly tuned database queries, end-points to support special UI needs, etc.
By separating these two concerns, your JSON API can be stable, regular and low-maintenance, and your hypermedia API can
be chaotic, specialized and flexible. Each gets its own controller environment to thrive in, without conflicting with
one another.
And this is why I prefer to split my JSON and hypermedia APIs up into separate controllers, rather than use HTTP content
negotiation to attempt to reuse controllers for both.

View File

@ -31,7 +31,7 @@ You can copy and paste them and then adjust them for your needs.
| [File Upload](@/examples/file-upload.md) | Demonstrates how to upload a file via ajax with a progress bar
| [Preserving File Inputs after Form Errors](@/examples/file-upload-input.md) | Demonstrates how to preserve file inputs after form errors
| [Dialogs - Browser](@/examples/dialogs.md) | Demonstrates the prompt and confirm dialogs
| [Dialogs - UIKIt](@/examples/modal-uikit.md) | Demonstrates modal dialogs using UIKit
| [Dialogs - UIKit](@/examples/modal-uikit.md) | Demonstrates modal dialogs using UIKit
| [Dialogs - Bootstrap](@/examples/modal-bootstrap.md) | Demonstrates modal dialogs using Bootstrap
| [Dialogs - Custom](@/examples/modal-custom.md) | Demonstrates modal dialogs from scratch
| [Tabs (Using HATEOAS)](@/examples/tabs-hateoas.md) | Demonstrates how to display and select tabs using HATEOAS principles

View File

@ -17,7 +17,7 @@ We start with a search input and an empty table:
<input class="form-control" type="search"
name="search" placeholder="Begin Typing To Search Users..."
hx-post="/search"
hx-trigger="keyup changed delay:500ms, search"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results"
hx-indicator=".htmx-indicator">
@ -34,16 +34,16 @@ We start with a search input and an empty table:
</table>
```
The input issues a `POST` to `/search` on the `keyup` event and sets the body of the table to be the resulting content.
The input issues a `POST` to `/search` on the [`input`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) event and sets the body of the table to be the resulting content. Note that the `keyup` event could be used as well, but would not fire if the user pasted text with their mouse (or any other non-keyboard method).
We add the `delay:500ms` modifier to the trigger to delay sending the query until the user stops typing. Additionally,
we add the `changed` modifier to the trigger to ensure we don't send new queries when the user doesn't change the
value of the input (e.g. they hit an arrow key).
value of the input (e.g. they hit an arrow key, or pasted the same value).
Since we use a `search` type input we will get an `x` in the input field to clear the input.
To make this trigger a new `POST` we have to specify another trigger. We specify another trigger by using a comma to
separate them. The `search` trigger will be run when the field is cleared but it also makes it possible to override
the 500 ms delay on `keyup` by just pressing enter.
the 500 ms `input` event delay by just pressing enter.
Finally, we show an indicator when the search is in flight with the `hx-indicator` attribute.
@ -78,7 +78,7 @@ Search Contacts
<input class="form-control" type="search"
name="search" placeholder="Begin Typing To Search Users..."
hx-post="/search"
hx-trigger="keyup changed delay:500ms, search"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results"
hx-indicator=".htmx-indicator">

View File

@ -12,6 +12,7 @@ title = "Reference"
* [htmx Events](#events)
* [htmx Extensions](/extensions#included)
* [JavaScript API](#api)
* [Configuration Options](#config)
## Core Attribute Reference {#attributes}
@ -24,7 +25,7 @@ The following are the most common attributes when using htmx.
| [`hx-boost`](@/attributes/hx-boost.md) | add or remove [progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement) for links and forms |
| [`hx-get`](@/attributes/hx-get.md) | issues a `GET` to the specified URL |
| [`hx-post`](@/attributes/hx-post.md) | issues a `POST` to the specified URL |
| [`hx-on`](@/attributes/hx-on.md) | handle any event with a script inline |
| [`hx-on*`](@/attributes/hx-on.md) | handle events with a inline scripts on elements |
| [`hx-push-url`](@/attributes/hx-push-url.md) | pushes the URL into the browser location bar, creating a new history entry |
| [`hx-select`](@/attributes/hx-select.md) | select content to swap in from a response |
| [`hx-select-oob`](@/attributes/hx-select-oob.md) | select content to swap in from a response, out of band (somewhere other than the target) |
@ -207,3 +208,51 @@ The table below lists all other attributes available in htmx.
</div>
## Configuration Reference {#config}
Htmx has some configuration options that can be accessed either programmatically or declaratively. They are
listed below:
<div class="info-table">
| Config Variable | Info |
|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `htmx.config.historyEnabled` | defaults to `true`, really only useful for testing |
| `htmx.config.historyCacheSize` | defaults to 10 |
| `htmx.config.refreshOnHistoryMiss` | defaults to `false`, if set to `true` htmx will issue a full page refresh on history misses rather than use an AJAX request |
| `htmx.config.defaultSwapStyle` | defaults to `innerHTML` |
| `htmx.config.defaultSwapDelay` | defaults to 0 |
| `htmx.config.defaultSettleDelay` | defaults to 20 |
| `htmx.config.includeIndicatorStyles` | defaults to `true` (determines if the indicator styles are loaded) |
| `htmx.config.indicatorClass` | defaults to `htmx-indicator` |
| `htmx.config.requestClass` | defaults to `htmx-request` |
| `htmx.config.addedClass` | defaults to `htmx-added` |
| `htmx.config.settlingClass` | defaults to `htmx-settling` |
| `htmx.config.swappingClass` | defaults to `htmx-swapping` |
| `htmx.config.allowEval` | defaults to `true`, can be used to disable htmx's use of eval for certain features (e.g. trigger filters) |
| `htmx.config.allowScriptTags` | defaults to `true`, determines if htmx will process script tags found in new content |
| `htmx.config.inlineScriptNonce` | defaults to `''`, meaning that no nonce will be added to inline scripts |
| `htmx.config.attributesToSettle` | defaults to `["class", "style", "width", "height"]`, the attributes to settle during the settling phase |
| `htmx.config.useTemplateFragments` | defaults to `false`, HTML template tags for parsing content from the server (not IE11 compatible!) |
| `htmx.config.wsReconnectDelay` | defaults to `full-jitter` |
| `htmx.config.wsBinaryType` | defaults to `blob`, the [the type of binary data](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) being received over the WebSocket connection |
| `htmx.config.disableSelector` | defaults to `[hx-disable], [data-hx-disable]`, htmx will not process elements with this attribute on it or a parent |
| `htmx.config.withCredentials` | defaults to `false`, allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates |
| `htmx.config.timeout` | defaults to 0, the number of milliseconds a request can take before automatically being terminated |
| `htmx.config.scrollBehavior` | defaults to 'smooth', the behavior for a boosted link on page transitions. The allowed values are `auto` and `smooth`. Smooth will smoothscroll to the top of the page while auto will behave like a vanilla link. |
| `htmx.config.defaultFocusScroll` | if the focused element should be scrolled into view, defaults to false and can be overridden using the [focus-scroll](@/attributes/hx-swap.md#focus-scroll) swap modifier. |
| `htmx.config.getCacheBusterParam` | defaults to false, if set to true htmx will include a cache-busting parameter in `GET` requests to avoid caching partial responses by the browser |
| `htmx.config.globalViewTransitions` | if set to `true`, htmx will use the [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) API when swapping in new content. |
| `htmx.config.methodsThatUseUrlParams` | defaults to `["get"]`, htmx will format requests with these methods by encoding their parameters in the URL, not the request body |
| `htmx.config.selfRequestsOnly` | defaults to `false`, if set to `true` will only allow AJAX requests to the same domain as the current document |
| `htmx.config.ignoreTitle` | defaults to `false`, if set to `true` htmx will not update the title of the document when a `title` tag is found in new content |
| `htmx.config.scrollIntoViewOnBoost` | defaults to `true`, whether or not the target of a boosted element is scrolled into the viewport. If `hx-target` is omitted on a boosted element, the target defaults to `body`, causing the page to scroll to the top. |
</div>
You can set them directly in javascript, or you can use a `meta` tag:
```html
<meta name="htmx-config" content='{"defaultSwapStyle":"outerHTML"}'>
```

View File

@ -138,6 +138,15 @@ These examples may make it a bit easier to get started using htmx with your plat
- <https://github.com/michalsn/codeigniter-htmx-demo>
### Laravel
- <https://spirofloropoulos.com/laravel-htmx-hard-mode-1.html>
### Symfony
- <https://github.com/tomcri/htmxfony>
## Elixir
### Phoenix

View File

@ -0,0 +1,33 @@
<!-- Generator: Adobe Illustrator 25.1.0, SVG Export Plug-In -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="189.51px" height="22.2px" viewBox="0 0 189.51 22.2" style="overflow:visible;enable-background:new 0 0 189.51 22.2;"
xml:space="preserve">
<style type="text/css">
.st0{fill:#00;}
</style>
<defs>
</defs>
<g>
<path class="st0" d="M16.59,16.11c0,4.11-2.34,5.94-6.63,5.94H0V0.15h8.04c4.56,0,6.9,2.07,6.9,5.97c0,3.15-2.04,4.14-2.73,4.35
C14.64,11.01,16.59,12.66,16.59,16.11z M3.15,3.3v6.27h4.71c2.67,0,3.93-0.99,3.93-3.09c0-2.01-1.2-3.18-4.14-3.18H3.15z M9.6,18.9
c2.55,0,3.84-0.84,3.84-3.03c0-2.1-1.26-3.39-4.14-3.39H3.15v6.42H9.6z"/>
<path class="st0" d="M32.73,18.9v3.15h-13.5V0.15h3.15V18.9H32.73z"/>
<path class="st0" d="M50.67,22.05c-0.84-2.37-1.65-4.62-2.4-6.63h-9.48c-0.72,2.01-1.53,4.26-2.37,6.63h-3.3
c4.89-13.89,8.46-21.9,8.46-21.9h3.9c0,0,3.57,8.01,8.49,21.9H50.67z M47.19,12.48C45,6.6,43.53,3.03,43.53,3.03
s-1.44,3.57-3.63,9.45H47.19z"/>
<path class="st0" d="M65.22,0c6,0,8.19,2.85,8.85,3.72c-0.54,1.8-1.74,2.91-1.74,2.91c-1.05-1.5-2.76-3.51-7.11-3.51
c-4.29,0-7.89,2.55-7.89,7.98c0,5.76,3.48,7.98,7.89,7.98c4.26,0,6.36-1.86,7.35-3.45c0,0,1.08,0.99,1.71,2.67
c-0.63,0.96-3.03,3.9-9.03,3.9c-6.27,0-11.07-3.63-11.07-11.1C54.18,3.87,58.95,0,65.22,0z"/>
<path class="st0" d="M91.68,22.05c-2.82-4.41-5.28-7.68-6.33-9.03c-1.56,0.96-3.36,1.71-5.37,2.28v6.75h-3.15V0.15h3.15v12
c5.16-1.41,8.91-4.77,10.8-12h3.15c-1.44,5.07-3.39,8.55-6.03,10.98c1.08,1.41,4.05,5.4,7.47,10.92H91.68z"/>
<path class="st0" d="M125.61,0.15v21.9h-4.95V14.1h-11.1v7.95h-4.95V0.15h4.95v9h11.1v-9H125.61z"/>
<path class="st0" d="M139.26,0c6.3,0,10.95,3.75,10.95,11.1c0,7.35-4.65,11.1-10.95,11.1s-10.95-3.75-10.95-11.1
C128.31,3.75,132.96,0,139.26,0z M139.26,17.25c3.75,0,6-2.4,6-6.15c0-3.75-2.25-6.15-6-6.15s-6,2.4-6,6.15
C133.26,14.85,135.51,17.25,139.26,17.25z"/>
<path class="st0" d="M164.91,7.95c0,0,0.3-0.45,0.3-1.35c0-1.35-0.75-2.37-3.9-2.37c-3,0-4.05,1.02-4.05,2.37
c0,4.5,13.2,0.9,13.2,9c0,4.2-2.55,6.6-9,6.6c-6.75,0-8.85-2.7-9.3-6.45c0,0,1.8-0.75,4.65-1.05c0.3,2.25,1.5,3.45,4.95,3.45
c3,0,4.05-0.75,4.05-2.25c0-4.2-13.35-0.75-13.35-9.45c0-3.75,2.55-6.45,9-6.45c6.75,0,8.7,2.7,8.7,5.55c0,1.8-0.45,2.4-0.45,2.4
H164.91z"/>
<path class="st0" d="M189.51,5.1h-6.6v16.95h-4.95V5.1h-6.6V0.15h18.15V5.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -28,8 +28,8 @@ function parseParams(str) {
str = str.substr(1);
}
while (e = re.exec(str)) {
var k = decode(e[1]);
var v = decode(e[2]);
var k = encodeHTML(decode(e[1]));
var v = encodeHTML(decode(e[2]));
if (params[k] !== undefined) {
if (!Array.isArray(params[k])) {
params[k] = [params[k]];
@ -52,6 +52,10 @@ function getQuery(url) {
url.substring(question + 1, hash);
}
function encodeHTML(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}
function params(request) {
if (server.getHTTPMethod(request) == "GET") {
return parseParams(getQuery(request.url));

View File

@ -200,7 +200,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
if (!this.socket) {
api.triggerErrorEvent()
}
if (sendElt && api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message: message,
socketWrapper: this.publicInterface
})) {

View File

@ -48,7 +48,7 @@ export function ajax(verb: string, path: string, selector: string): Promise<void
export function ajax(
verb: string,
path: string,
context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any }>
context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any; select: any }>
): Promise<void>;
/**
@ -395,6 +395,11 @@ export interface HtmxConfig {
* @default false
*/
selfRequestsOnly?: boolean;
/**
* Whether or not the target of a boosted element is scrolled into the viewport.
* @default true
*/
scrollIntoViewOnBoost?: boolean;
}
/**

View File

@ -75,6 +75,7 @@ return (function () {
globalViewTransitions: false,
methodsThatUseUrlParams: ["get"],
selfRequestsOnly: false,
ignoreTitle: false,
scrollIntoViewOnBoost: true
},
parseInterval:parseInterval,
@ -87,7 +88,7 @@ return (function () {
sock.binaryType = htmx.config.wsBinaryType;
return sock;
},
version: "1.9.8"
version: "1.9.9"
};
/** @type {import("./htmx").HtmxInternalApi} */
@ -1145,6 +1146,8 @@ return (function () {
var SYMBOL_CONT = /[_$a-zA-Z0-9]/;
var STRINGISH_START = ['"', "'", "/"];
var NOT_WHITESPACE = /[^\s]/;
var COMBINED_SELECTOR_START = /[{(]/;
var COMBINED_SELECTOR_END = /[})]/;
function tokenizeString(str) {
var tokens = [];
var position = 0;
@ -1233,6 +1236,18 @@ return (function () {
return result;
}
function consumeCSSSelector(tokens) {
var result;
if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) {
tokens.shift();
result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim();
tokens.shift();
} else {
result = consumeUntil(tokens, WHITESPACE_OR_COMMA);
}
return result;
}
var INPUT_SELECTOR = 'input, textarea, select';
/**
@ -1281,29 +1296,33 @@ return (function () {
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
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 = consumeUntil(
tokens,
WHITESPACE_OR_COMMA
)
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
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 = consumeUntil(tokens, WHITESPACE_OR_COMMA);
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" || token === "threshold") && tokens[0] === ":") {
} 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 {
@ -2906,6 +2925,7 @@ return (function () {
values : context.values,
targetOverride: resolveTarget(context.target),
swapOverride: context.swap,
select: context.select,
returnPromise: true
});
}
@ -2960,6 +2980,7 @@ return (function () {
elt = getDocument().body;
}
var responseHandler = etc.handler || handleAjaxResponse;
var select = etc.select || null;
if (!bodyContains(elt)) {
// do not issue requests for elements removed from the DOM
@ -3108,6 +3129,11 @@ return (function () {
var headers = getHeaders(elt, target, promptResponse);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (etc.headers) {
headers = mergeObjects(headers, etc.headers);
}
@ -3121,10 +3147,6 @@ return (function () {
var allParameters = mergeObjects(rawParameters, expressionVars);
var filteredParameters = filterValues(allParameters, elt);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (htmx.config.getCacheBusterParam && verb === 'get') {
filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true";
}
@ -3222,7 +3244,7 @@ return (function () {
}
var responseInfo = {
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted,
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select,
pathInfo: {
requestPath: path,
finalRequestPath: finalPath,
@ -3393,6 +3415,7 @@ return (function () {
var target = responseInfo.target;
var etc = responseInfo.etc;
var requestConfig = responseInfo.requestConfig;
var select = responseInfo.select;
if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return;
@ -3502,10 +3525,26 @@ return (function () {
}
var selectOverride;
if (select) {
selectOverride = select;
}
if (hasHeader(xhr, /HX-Reselect:/i)) {
selectOverride = xhr.getResponseHeader("HX-Reselect");
}
// if we need to save history, do so, before swapping so that relative resources have the correct base URL
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
var settleInfo = makeSettleInfo(target);
selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride);
@ -3555,17 +3594,6 @@ return (function () {
triggerEvent(elt, 'htmx:afterSettle', responseInfo);
});
// if we need to save history, do so
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
if (responseInfo.pathInfo.anchor) {
var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor);
if(anchorTarget) {
@ -3724,25 +3752,34 @@ return (function () {
//====================================================================
// Initialization
//====================================================================
var isReady = false
getDocument().addEventListener('DOMContentLoaded', function() {
isReady = true
})
/**
* 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.
* 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(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);
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;
};
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();
});
}
}
@ -3750,9 +3787,9 @@ return (function () {
if (htmx.config.includeIndicatorStyles !== false) {
getDocument().head.insertAdjacentHTML("beforeend",
"<style>\
." + htmx.config.indicatorClass + "{opacity:0;transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.indicatorClass + "{opacity:0}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
</style>");
}
}

View File

@ -116,4 +116,3 @@ describe("hx-boost attribute", function() {
});
});

View File

@ -895,5 +895,64 @@ describe("hx-trigger attribute", function(){
form.innerHTML.should.equal("Called!");
})
it("correctly handles CSS descendant combinators", function(){
this.server.respondWith("GET", "/test", "Clicked!");
var outer = make(`
<div>
<div id='outer'>
<div id='first'>
<div id='inner'></div>
</div>
<div id='second' hx-get='/test' hx-trigger='click from:previous (#outer div)'>Unclicked.</div>
</div>
<div id='other' hx-get='/test' hx-trigger='click from:(div #inner)'>Unclicked.</div>
</div>
`);
var inner = byId("inner");
var second = byId("second");
var other = byId("other");
second.innerHTML.should.equal("Unclicked.");
other.innerHTML.should.equal("Unclicked.");
inner.click();
this.server.respond();
second.innerHTML.should.equal("Clicked!");
other.innerHTML.should.equal("Clicked!");
})
it('correctly handles CSS descendant combinators in modifier target', function() {
this.server.respondWith('GET', '/test', 'Called');
document.addEventListener('htmx:syntax:error', function(evt) {
chai.assert.fail('htmx:syntax:error');
});
make('<div class="d1"><a id="a1" class="a1">Click me</a><a id="a2" class="a2">Click me</a></div>');
var div = make('<div hx-trigger="click from:body target:(.d1 .a2)" hx-get="/test">Not Called</div>');
byId('a1').click();
this.server.respond();
div.innerHTML.should.equal("Not Called");
byId('a2').click();
this.server.respond();
div.innerHTML.should.equal("Called");
});
it('correctly handles CSS descendant combinators in modifier root', function() {
this.server.respondWith('GET', '/test', 'Called');
document.addEventListener('htmx:syntax:error', function(evt) {
chai.assert.fail('htmx:syntax:error');
});
make('<div hx-trigger="intersect root:{form input}" hx-get="/test">Not Called</div>');
});
})

View File

@ -1284,6 +1284,8 @@ describe("Core htmx AJAX Tests", function(){
byId("submit").click();
this.server.respond();
responded.should.equal(true);
})
it("can associate submit buttons from outside a form with the current version of the form after swap", function(){
const template = '<form ' +
'id="hello" ' +

View File

@ -225,6 +225,24 @@ describe("Core htmx API test", function(){
div.innerHTML.should.equal('<p class="test">foo!</p>');
});
it('ajax api works with select', function()
{
this.server.respondWith("GET", "/test", "<div id='d1'>foo</div><div id='d2'>bar</div>");
var div = make("<div id='target'></div>");
htmx.ajax("GET", "/test", {target: "#target", select: "#d2"});
this.server.respond();
div.innerHTML.should.equal('<div id="d2">bar</div>');
});
it('ajax api works with Hx-Select overrides select', function()
{
this.server.respondWith("GET", "/test", [200, {"HX-Reselect": "#d2"}, "<div id='d1'>foo</div><div id='d2'>bar</div>"]);
var div = make("<div id='target'></div>");
htmx.ajax("GET", "/test", {target: "#target", select: "#d1"});
this.server.respond();
div.innerHTML.should.equal('<div id="d2">bar</div>');
});
it('ajax returns a promise', function(done)
{
// in IE we do not return a promise
@ -255,6 +273,44 @@ describe("Core htmx API test", function(){
div.innerHTML.should.equal("Clicked!");
});
it('ajax api Content-Type header is application/x-www-form-urlencoded', function(){
this.server.respondWith("POST", "/test", function (xhr) {
var params = getParameters(xhr);
xhr.requestHeaders['Content-Type'].should.equal('application/x-www-form-urlencoded;charset=utf-8');
params['i1'].should.equal("test");
xhr.respond(200, {}, "Clicked!")
});
var div = make("<div id='d1'></div>");
htmx.ajax("POST", "/test", {target:"#d1", values:{i1: 'test'}})
this.server.respond();
div.innerHTML.should.equal("Clicked!");
});
it('ajax api Content-Type header override to application/json', function(){
this.server.respondWith("POST", "/test", function (xhr) {
var params = getParameters(xhr);
xhr.requestHeaders['Content-Type'].should.equal('application/json;charset=utf-8');
params['i1'].should.equal("test");
xhr.respond(200, {}, "Clicked!");
});
var div = make("<div id='d1'></div>");
htmx.ajax('POST',"/test", {
target:'#d1',
swap:'innerHTML',
headers: {
'Content-Type': 'application/json'
},
values:{i1: 'test'}
})
this.server.respond();
div.innerHTML.should.equal("Clicked!");
});
it('can re-init with new attributes', function () {
this.server.respondWith("PATCH", "/test", "patch");
this.server.respondWith("DELETE", "/test", "delete");

View File

@ -614,4 +614,205 @@ describe("web-sockets extension", function () {
this.messages[1].should.contains('"foo":"bar"')
this.messages[1].should.contains('"action":"B"')
})
describe("Send immediately", function() {
function checkCallForWsBeforeSend(spy, wrapper, message, target) {
// Utility function to always check the same for htmx:wsBeforeSend caught by a spy
spy.calledOnce.should.be.true;
var call = spy.getCall(0);
call.args.length.should.equal(1);
var arg = call.args[0];
arg.target.should.equal(target);
arg.detail.socketWrapper.should.equal(wrapper);
arg.detail.message.should.equal(message);
}
it('triggers wsBeforeSend on body if provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"foo":"bar"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
window.document.body.addEventListener("htmx:wsBeforeSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, window.document.body)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body)
} finally {
window.document.body.removeEventListener("htmx:wsBeforeSend", myEventCalled)
}
done()
}
try {
window.document.addEventListener("htmx:wsOpen", handler)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('triggers wsBeforeSend on any send element provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsBeforeSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, d1)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1)
} finally {
id1.removeEventListener("htmx:wsBeforeSend", myEventCalled)
}
done()
}
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('triggers wsAfterSend on body if provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"foo":"bar"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
window.document.body.addEventListener("htmx:wsAfterSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, window.document.body)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body)
} finally {
window.document.body.removeEventListener("htmx:wsAfterSend", myEventCalled)
}
done()
}
try {
window.document.addEventListener("htmx:wsOpen", handler)
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('triggers wsAfterSend on any send element provided to sendImmediately', function (done) {
var myEventCalled = sinon.spy();
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsAfterSend", myEventCalled)
try {
socketWrapper.sendImmediately(message, d1)
checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1)
} finally {
id1.removeEventListener("htmx:wsAfterSend", myEventCalled)
}
done()
}
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('sends message if event is not prevented', function (done) {
var message = '{"a":"b"}';
var noop = function() {}
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsBeforeSend", noop)
try {
socketWrapper.sendImmediately(message, d1)
this.tickMock();
this.messages.should.eql([message])
} finally {
id1.removeEventListener("htmx:wsBeforeSend", noop)
}
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('sends message if no sending element is provided', function (done) {
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
socketWrapper.sendImmediately(message)
this.tickMock();
this.messages.should.eql([message])
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('sends message if sending element has no event listener for beforeSend', function (done) {
var message = '{"a":"b"}';
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var d1 = byId("d1");
socketWrapper.sendImmediately(message, d1)
this.tickMock();
this.messages.should.eql([message])
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
it('does not send message if beforeSend is prevented', function (done) {
var message = '{"a":"b"}';
var eventPrevented = function(e) {e.preventDefault()}
var handler = function(e){
var socketWrapper = e.detail.socketWrapper;
var id1 = byId("d1");
id1.addEventListener("htmx:wsBeforeSend", eventPrevented)
try {
socketWrapper.sendImmediately(message, d1)
this.tickMock();
this.messages.should.eql([])
} finally {
id1.removeEventListener("htmx:wsBeforeSend", eventPrevented)
}
done()
}.bind(this)
window.document.addEventListener("htmx:wsOpen", handler)
try {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div></div>');
this.tickMock();
} finally {
window.document.removeEventListener("htmx:wsOpen", handler)
}
})
})
});

View File

@ -0,0 +1,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<script type="application/javascript" src="../../../src/htmx.js"></script>
<title>hx-boost - Relative Resources Page 1</title>
</head>
<body style="padding:20px;font-family: sans-serif" hx-boost="true">
<a href="nested/page2.html">To Page 2</a>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,12 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<script type="application/javascript" src="../../../../src/htmx.js"></script>
<title>hx-boost - Relative Resources Page 2</title>
</head>
<body style="padding:20px;font-family: sans-serif" hx-boost="true">
<a href="../index.html">Back To Page 1</a>
<p>Image should be displayed below</p>
<img src="img.png" />
</body>
</html>

View File

@ -37,6 +37,12 @@
<li><a href="history_style">History Style</a></li>
</ul>
</li>
<li>Boost Tests
<ul>
<li><a href="hxboost_relative_resources">Relative Resources</a></li>
<li><a href="hxboost_template_parsing">Template Parsing</a></li>
</ul>
</li>
</ul>
<h2>Perf</h2>
<li><a href="manual-perf.html">Manual Perf Test</a></li>

View File

@ -0,0 +1,244 @@
import * as http from 'node:http'
import * as path from 'node:path'
import * as fs from 'node:fs/promises'
import { WebSocketServer } from 'ws'
// Define some string and number constants
const HOSTNAME = '127.0.0.1';
const PORT = 8080;
const DATA = JSON.parse(await fs.readFile('./static/data.json'))
const SITE_BASE = (await fs.readFile('./static/site-base.html')).toString()
// Define the websockets
const ECHO_WS = createWebSocket((ws) => {
ws.on('message', (message) => {
const data = JSON.parse(message.toString())
ws.send(`<div id=idMessage>${data.message}</div>`)
})
})
const HEARTBEAT_WS = createWebSocket((ws) => {
ws.interval = setInterval(() => {
const num = Math.trunc(Math.random() * 10**10)
ws.send(`<div id=idMessage>${num}</div>`)
}, 1000)
}, (ws) => clearInterval(ws.interval))
// Define the server
const server = http.createServer(async (req, res) => {
try {
await handleRequest(req, res)
} catch (error) {
console.error(`Error serving ${req.url}`)
console.error(req.body)
console.error(error)
}
})
// This handles all the non-websocket requests
async function handleRequest (req, res) {
// If the URL starts with htmx, serve the src/ root version of htmx
if (req.url.startsWith('/htmx')) {
const resource = req.url.substring(6)
res.setHeader('Content-Type', 'text/javascript')
const fp = path.join('../../src', resource)
return serveFile(res, fp)
}
// If the URL matches one of these, it's an event stream
if (req.url.startsWith("/posts.html")) return servePosts(req, res)
if (req.url === "/comments.html") return makeStream(req, res, DATA.comments, formatComment)
if (req.url === "/albums.html") return makeStream(req, res, DATA.albums, formatAlbum)
if (req.url === "/todos.html") return makeStream(req, res, DATA.todos, formatTodo)
if (req.url === "/users.html") return makeStream(req, res, DATA.users, formatUser)
// Randomly-generated HTML
if (req.url === "/page/random") return serveRandomHtml(req, res)
// Otherwise, attempt to serve the file from ./static and return a 404 on failure
try {
await serveFileFromStatic(req, res)
} catch (error) {
sendNotFound(res)
}
}
// Attach the websockets
server.on('upgrade', (request, socket, head) => {
if (request.url === '/echo') ECHO_WS.handle(request, socket, head)
if (request.url === '/heartbeat') HEARTBEAT_WS.handle(request, socket, head)
})
// Start listening
server.listen(PORT, HOSTNAME, () => {
console.log('Loading the WebSocket / Server-Side Event Tests...');
console.log(`You can run them at http://${HOSTNAME}:${PORT}/`);
})
function createWebSocket (connectionFunc, closeFunc) {
const server = new WebSocketServer({ noServer: true })
server.on('connection', connectionFunc)
if (closeFunc) server.on('close', closeFunc)
const handle = (request, socket, head) => {
server.handleUpgrade(request, socket, head, (ws) => {
server.emit('connection', ws, request)
})
}
return { handle }
}
async function serveFileFromStatic (req, res) {
// For the root, serve the static index.html file
const resource = req.url === '/' ? '/index.html' : req.url
let fp = path.join('./static/', resource)
let lstat = await fs.lstat(fp)
// If it's a directory, re-set the fp to be the index.html of that directory
if (lstat.isDirectory()) {
fp = path.join(fp, 'index.html')
lstat = await fs.lstat(fp)
}
if (!lstat.isFile) return sendNotFound(res)
const withBase = fp.endsWith('.html')
return serveFile(res, fp, withBase)
}
async function serveFile (res, fp, withBase) {
try {
const file = await fs.readFile(fp)
let text = file.toString()
if (withBase) text = SITE_BASE + text
res.end(text)
} catch (error) {
console.error(error)
sendNotFound(res)
}
}
function servePosts (req, res) {
// Why do we have to specify a fake protocol here? Because WHATWG doesn't support relative URLs
// Maddening discussion here: https://github.com/whatwg/url/issues/531
const url = new URL(req.url, "thismessage:/")
const types = url.searchParams?.get('types')
const numEvents = types ? types.split(',').length : 0
makeStream(req, res, DATA.posts, formatPost, numEvents)
}
function sendNotFound(res) {
res.statusCode = 404
res.setHeader('Content-Type', 'text/plain')
res.end('404 NOT FOUND')
}
function makeStream(req, res, arr, formatFunc, numEvents = 0) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control': 'no-cache'
})
// Make the intervals somewhat random, between 200 and 400ms
// We have some tests that create multiple streams at once, so this ensures they're all visibile
const intervalLength = Math.floor(Math.random() * 200) + 200
let i = 0
const interval = setInterval(() => {
if (i == arr.length) i = 0
const item = arr[i]
try {
const evenNum = Math.floor(Math.random() * numEvents) + 1
const eventName = numEvents > 0 ? `Event${evenNum}` : '(none)'
item.event = eventName
const formattedData = formatFunc(item).replace(/\n/g, ' ')
const event = `${numEvents > 0 ? `event: ${eventName}\n` : ''}data: ${formattedData}\n\n`
res.write(event)
i++
} catch (error) {
// Stop the interval if it errors for any reason
clearInterval(interval)
}
}, intervalLength)
req.on('close', () => {
res.end('OK')
clearInterval(interval)
})
}
function serveRandomHtml(_req, res) {
const page_num = Math.trunc(Math.random() * 10**10)
const html_num = Math.trunc(Math.random() * 10**10)
const html = `
<div>
This is page ${page_num}
<br><br>
Randomly generated <b>HTML</b> ${html_num}
<br><br>
I wish I were a haiku.
</div>
`
res.end(html)
}
function formatPost (post) {
return `
<div>
<div class="bold">Post: ${post.title}</div>
<div>${post.body}</div>
<div>id: ${post.id}</div>
<div>user: ${post.userId}</div>
<div>event: ${post.event}</div>
</div>
`
}
function formatComment (comment) {
return `
<div>
<div class="bold">Comment: ${comment.name}</div>
<div>${comment.email}</div>
<div>id: ${comment.body}</div>
<div>event: ${comment.event}</div>
</div>
`
}
function formatAlbum (album) {
return `
<div>
<div class="bold">Album: ${album.title}</div>
<div>id: ${album.id}</div>
<div>event: ${album.event}</div>
</div>
`
}
function formatTodo (todo) {
return `
<div>
<div class="bold">To-Do: ${todo.title}</div>
<div>complete? ${todo.completed}</div>
<div>event: ${todo.event}</div>
</div>
`
}
function formatUser (user) {
return `
<div>
<div class="bold">User: ${user.name}</div>
<div>${user.email}</div>
<div>${user.address.street} ${user.address.suite}<br>${user.address.city}, ${user.address.zipcode}</div>
<div>event: ${user.event}</div>
</div>
`
}

View File

@ -75,6 +75,7 @@ return (function () {
globalViewTransitions: false,
methodsThatUseUrlParams: ["get"],
selfRequestsOnly: false,
ignoreTitle: false,
scrollIntoViewOnBoost: true
},
parseInterval:parseInterval,
@ -87,7 +88,7 @@ return (function () {
sock.binaryType = htmx.config.wsBinaryType;
return sock;
},
version: "1.9.8"
version: "1.9.9"
};
/** @type {import("./htmx").HtmxInternalApi} */
@ -1145,6 +1146,8 @@ return (function () {
var SYMBOL_CONT = /[_$a-zA-Z0-9]/;
var STRINGISH_START = ['"', "'", "/"];
var NOT_WHITESPACE = /[^\s]/;
var COMBINED_SELECTOR_START = /[{(]/;
var COMBINED_SELECTOR_END = /[})]/;
function tokenizeString(str) {
var tokens = [];
var position = 0;
@ -1233,6 +1236,18 @@ return (function () {
return result;
}
function consumeCSSSelector(tokens) {
var result;
if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) {
tokens.shift();
result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim();
tokens.shift();
} else {
result = consumeUntil(tokens, WHITESPACE_OR_COMMA);
}
return result;
}
var INPUT_SELECTOR = 'input, textarea, select';
/**
@ -1281,29 +1296,33 @@ return (function () {
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
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 = consumeUntil(
tokens,
WHITESPACE_OR_COMMA
)
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
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 = consumeUntil(tokens, WHITESPACE_OR_COMMA);
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" || token === "threshold") && tokens[0] === ":") {
} 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 {
@ -2906,6 +2925,7 @@ return (function () {
values : context.values,
targetOverride: resolveTarget(context.target),
swapOverride: context.swap,
select: context.select,
returnPromise: true
});
}
@ -2960,6 +2980,7 @@ return (function () {
elt = getDocument().body;
}
var responseHandler = etc.handler || handleAjaxResponse;
var select = etc.select || null;
if (!bodyContains(elt)) {
// do not issue requests for elements removed from the DOM
@ -3108,6 +3129,11 @@ return (function () {
var headers = getHeaders(elt, target, promptResponse);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (etc.headers) {
headers = mergeObjects(headers, etc.headers);
}
@ -3121,10 +3147,6 @@ return (function () {
var allParameters = mergeObjects(rawParameters, expressionVars);
var filteredParameters = filterValues(allParameters, elt);
if (verb !== 'get' && !usesFormData(elt)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (htmx.config.getCacheBusterParam && verb === 'get') {
filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true";
}
@ -3222,7 +3244,7 @@ return (function () {
}
var responseInfo = {
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted,
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select,
pathInfo: {
requestPath: path,
finalRequestPath: finalPath,
@ -3393,6 +3415,7 @@ return (function () {
var target = responseInfo.target;
var etc = responseInfo.etc;
var requestConfig = responseInfo.requestConfig;
var select = responseInfo.select;
if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return;
@ -3502,10 +3525,26 @@ return (function () {
}
var selectOverride;
if (select) {
selectOverride = select;
}
if (hasHeader(xhr, /HX-Reselect:/i)) {
selectOverride = xhr.getResponseHeader("HX-Reselect");
}
// if we need to save history, do so, before swapping so that relative resources have the correct base URL
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
var settleInfo = makeSettleInfo(target);
selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride);
@ -3555,17 +3594,6 @@ return (function () {
triggerEvent(elt, 'htmx:afterSettle', responseInfo);
});
// if we need to save history, do so
if (historyUpdate.type) {
triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo));
if (historyUpdate.type === "push") {
pushUrlIntoHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path});
} else {
replaceUrlInHistory(historyUpdate.path);
triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path});
}
}
if (responseInfo.pathInfo.anchor) {
var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor);
if(anchorTarget) {
@ -3724,25 +3752,34 @@ return (function () {
//====================================================================
// Initialization
//====================================================================
var isReady = false
getDocument().addEventListener('DOMContentLoaded', function() {
isReady = true
})
/**
* 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.
* 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(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);
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;
};
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();
});
}
}
@ -3750,9 +3787,9 @@ return (function () {
if (htmx.config.includeIndicatorStyles !== false) {
getDocument().head.insertAdjacentHTML("beforeend",
"<style>\
." + htmx.config.indicatorClass + "{opacity:0;transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1}\
." + htmx.config.indicatorClass + "{opacity:0}\
." + htmx.config.requestClass + " ." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
." + htmx.config.requestClass + "." + htmx.config.indicatorClass + "{opacity:1; transition: opacity 200ms ease-in;}\
</style>");
}
}