Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Carson Gross 2023-07-14 13:50:31 -06:00
commit 05a1f8cba7
34 changed files with 3033 additions and 356 deletions

View File

@ -1,22 +0,0 @@
version: 2.1
commands:
orbs:
browser-tools: circleci/browser-tools@1.1.0
jobs:
test:
docker:
- image: cimg/node:16.13.1-browsers
steps:
- browser-tools/install-browser-tools
- checkout
- run: |
node --version
java --version
google-chrome --version
workflows:
tests-containers:
jobs:
- test

19
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,19 @@
name: Node CI
on:
push:
branches: [ master, dev, htmx-2.0 ]
pull_request:
branches: [ master, dev, htmx-2.0 ]
jobs:
test_suite:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '15.x'
- run: npm ci
- run: npm test

View File

@ -1,6 +1,8 @@
# Changelog
## [1.9.3] - 2023-04-28
## [1.9.3] - 2023-06-??
* Fixed bug w/ WebSocket extension initilization caused by "naked" `hx-trigger` feature
## [1.9.2] - 2023-04-28

2306
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,9 @@
"types": "dist/htmx.d.ts",
"unpkg": "dist/htmx.min.js",
"web-types": "editors/jetbrains/htmx.web-types.json",
"engines": {
"node": "15.x"
},
"scripts": {
"test": "mocha-chrome test/index.html",
"test-types": "tsc --project ./jsconfig.json",

View File

@ -48,9 +48,12 @@ htmx.defineExtension("preload", {
// in the future
var hxGet = node.getAttribute("hx-get") || node.getAttribute("data-hx-get")
if (hxGet) {
htmx.ajax("GET", hxGet, {handler:function(elt, info) {
htmx.ajax("GET", hxGet, {
source: node,
handler:function(elt, info) {
done(info.xhr.responseText);
}});
}
});
return;
}

View File

@ -52,7 +52,7 @@ This extension adds support for WebSockets to htmx. See /www/extensions/ws.md f
return;
// Try to create websockets when elements are processed
case "htmx:afterProcessNode":
case "htmx:beforeProcessNode":
var parent = evt.target;
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {

View File

@ -44,6 +44,7 @@ return (function () {
defineExtension : defineExtension,
removeExtension : removeExtension,
logAll : logAll,
logNone : logNone,
logger : null,
config : {
historyEnabled:true,
@ -71,6 +72,7 @@ return (function () {
defaultFocusScroll: false,
getCacheBusterParam: false,
globalViewTransitions: false,
methodsThatUseUrlParams: ["get"],
},
parseInterval:parseInterval,
_:internalEval,
@ -478,6 +480,10 @@ return (function () {
}
}
function logNone() {
htmx.logger = null
}
function find(eltOrSelector, selector) {
if (selector) {
return eltOrSelector.querySelector(selector);
@ -905,6 +911,17 @@ return (function () {
return hash;
}
function deInitOnHandlers(elt) {
var internalData = getInternalData(elt);
if (internalData.onHandlers) {
for (let i = 0; i < internalData.onHandlers.length; i++) {
const handlerInfo = internalData.onHandlers[i];
elt.removeEventListener(handlerInfo.name, handlerInfo.handler);
}
delete internalData.onHandlers
}
}
function deInitNode(element) {
var internalData = getInternalData(element);
if (internalData.timeout) {
@ -923,12 +940,7 @@ return (function () {
}
});
}
if (internalData.onHandlers) {
for (let i = 0; i < internalData.onHandlers.length; i++) {
const handlerInfo = internalData.onHandlers[i];
element.removeEventListener(handlerInfo.name, handlerInfo.handler);
}
}
deInitOnHandlers(element);
}
function cleanUpElement(element) {
@ -953,7 +965,7 @@ return (function () {
newElt = eltBeforeNewContent.nextSibling;
}
getInternalData(target).replacedWith = newElt; // tuck away so we can fire events on it later
settleInfo.elts = [] // clear existing elements
settleInfo.elts = settleInfo.elts.filter(e => e != target);
while(newElt && newElt !== target) {
if (newElt.nodeType === Node.ELEMENT_NODE) {
settleInfo.elts.push(newElt);
@ -1656,6 +1668,9 @@ return (function () {
var sseEventSource = getInternalData(sseSourceElt).sseEventSource;
var sseListener = function (event) {
if (maybeCloseSSESource(sseSourceElt)) {
return;
}
if (!bodyContains(elt)) {
sseEventSource.removeEventListener(sseEventName, sseListener);
return;
}
@ -1672,7 +1687,7 @@ return (function () {
var target = getTarget(elt)
var settleInfo = makeSettleInfo(elt);
selectAndSwap(swapSpec.swapStyle, elt, target, response, settleInfo)
selectAndSwap(swapSpec.swapStyle, target, elt, response, settleInfo)
settleImmediately(settleInfo.tasks)
triggerEvent(elt, "htmx:sseMessage", event)
};
@ -1826,6 +1841,16 @@ return (function () {
return document.querySelector("[hx-boost], [data-hx-boost]");
}
function findHxOnWildcardElements(elt) {
if (!document.evaluate) return []
let node = null
const elements = []
const iter = document.evaluate('//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") ]]', elt)
while (node = iter.iterateNext()) elements.push(node)
return elements
}
function findElementsToProcess(elt) {
if (elt.querySelectorAll) {
var boostedElts = hasChanceOfBeingBoosted() ? ", a, form" : "";
@ -1909,6 +1934,22 @@ return (function () {
}
}
function processHxOnWildcard(elt) {
// wipe any previous on handlers so that this function takes precedence
deInitOnHandlers(elt)
for (const attr of elt.attributes) {
const { name, value } = attr
if (name.startsWith("hx-on:") || name.startsWith("data-hx-on:")) {
let eventName = name.slice(name.indexOf(":") + 1)
// if the eventName starts with a colon, prepend "htmx" for shorthand support
if (eventName.startsWith(":")) eventName = "htmx" + eventName
addHxOnEventHandler(elt, eventName, value)
}
}
}
function initNode(elt) {
if (elt.closest && elt.closest(htmx.config.disableSelector)) {
return;
@ -1965,6 +2006,9 @@ return (function () {
elt = resolveTarget(elt);
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);
}
//====================================================================
@ -2933,8 +2977,12 @@ return (function () {
var requestAttrValues = getValuesForElement(elt, 'hx-request');
var eltIsBoosted = getInternalData(elt).boosted;
var useUrlParams = htmx.config.methodsThatUseUrlParams.indexOf(verb) >= 0
var requestConfig = {
boosted: eltIsBoosted,
useUrlParams: useUrlParams,
parameters: filteredParameters,
unfilteredParameters: allParameters,
headers:headers,
@ -2959,6 +3007,7 @@ return (function () {
headers = requestConfig.headers;
filteredParameters = requestConfig.parameters;
errors = requestConfig.errors;
useUrlParams = requestConfig.useUrlParams;
if(errors && errors.length > 0){
triggerEvent(elt, 'htmx:validation:halted', requestConfig)
@ -2970,26 +3019,25 @@ return (function () {
var splitPath = path.split("#");
var pathNoAnchor = splitPath[0];
var anchor = splitPath[1];
var finalPathForGet = null;
if (verb === 'get') {
finalPathForGet = pathNoAnchor;
var finalPath = path
if (useUrlParams) {
finalPath = pathNoAnchor;
var values = Object.keys(filteredParameters).length !== 0;
if (values) {
if (finalPathForGet.indexOf("?") < 0) {
finalPathForGet += "?";
if (finalPath.indexOf("?") < 0) {
finalPath += "?";
} else {
finalPathForGet += "&";
finalPath += "&";
}
finalPathForGet += urlEncode(filteredParameters);
finalPath += urlEncode(filteredParameters);
if (anchor) {
finalPathForGet += "#" + anchor;
finalPath += "#" + anchor;
}
}
xhr.open('GET', finalPathForGet, true);
} else {
xhr.open(verb.toUpperCase(), path, true);
}
xhr.open(verb.toUpperCase(), finalPath, true);
xhr.overrideMimeType("text/html");
xhr.withCredentials = requestConfig.withCredentials;
xhr.timeout = requestConfig.timeout;
@ -3010,7 +3058,7 @@ return (function () {
xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted,
pathInfo: {
requestPath: path,
finalRequestPath: finalPathForGet || path,
finalRequestPath: finalPath,
anchor: anchor
}
};
@ -3085,7 +3133,8 @@ return (function () {
});
});
triggerEvent(elt, 'htmx:beforeSend', responseInfo);
xhr.send(verb === 'get' ? null : encodeParamsForBody(xhr, elt, filteredParameters));
var params = useUrlParams ? null : encodeParamsForBody(xhr, elt, filteredParameters)
xhr.send(params);
return promise;
}

View File

@ -1,6 +1,5 @@
describe("hx-boost attribute", function() {
htmx.logAll();
beforeEach(function () {
this.server = makeServer();
clearWorkArea();

View File

@ -0,0 +1,133 @@
describe("hx-on:* attribute", function() {
beforeEach(function () {
this.server = makeServer();
clearWorkArea();
});
afterEach(function () {
this.server.restore();
clearWorkArea();
});
it("can handle basic events w/ no other attributes", 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);
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);
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>");
});
var btn = make("<button hx-on:htmx:config-request='event.preventDefault()' hx-post='/test' hx-swap='outerHTML'>Foo</button>");
btn.click();
this.server.respond();
btn.innerText.should.equal("Foo");
});
it("can respond to data-hx-on", function () {
this.server.respondWith("POST", "/test", function (xhr) {
var params = parseParams(xhr.requestBody);
xhr.respond(200, {}, params.foo);
});
var btn = make("<button data-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("has the this symbol set to the element", function () {
this.server.respondWith("POST", "/test", function (xhr) {
xhr.respond(200, {}, "foo");
});
var btn = make("<button hx-on:htmx:config-request='window.elt = this' hx-post='/test'>Foo</button>");
btn.click();
this.server.respond();
btn.innerText.should.equal("foo");
btn.should.equal(window.elt);
delete window.elt;
});
it("can handle multi-line JSON", function () {
this.server.respondWith("POST", "/test", function (xhr) {
xhr.respond(200, {}, "foo");
});
var btn = make("<button hx-on:htmx:config-request='window.elt = {foo: true,\n" +
" bar: false}' hx-post='/test'>Foo</button>");
btn.click();
this.server.respond();
btn.innerText.should.equal("foo");
var obj = {foo: true, bar: false};
obj.should.deep.equal(window.elt);
delete window.elt;
});
it("can handle multiple event handlers in the presence of multi-line JSON", function () {
this.server.respondWith("POST", "/test", function (xhr) {
xhr.respond(200, {}, "foo");
});
var btn = make("<button hx-on:htmx:config-request='window.elt = {foo: true,\n" +
" bar: false}\n'" +
" hx-on:htmx:after-request='window.foo = true'" +
" hx-post='/test'>Foo</button>");
btn.click();
this.server.respond();
btn.innerText.should.equal("foo");
var obj = {foo: true, bar: false};
obj.should.deep.equal(window.elt);
delete window.elt;
window.foo.should.equal(true);
delete window.foo;
});
it("de-initializes hx-on-* content properly", function () {
window.tempCount = 0;
this.server.respondWith("POST", "/test", function (xhr) {
xhr.respond(200, {}, "<button id='foo' hx-on:click=\"window.tempCount++;\">increment</button>");
});
var div = make("<div hx-post='/test'>Foo</div>");
// get response
div.click();
this.server.respond();
// click button
byId('foo').click();
window.tempCount.should.equal(1);
// get second response
div.click();
this.server.respond();
// click button again
byId('foo').click();
window.tempCount.should.equal(2);
delete window.tempCount;
});
});

View File

@ -241,13 +241,13 @@ describe("Core htmx API test", function(){
div3.classList.contains("foo").should.equal(true);
});
it('logAll works', function () {
var initialLogger = htmx.config.logger
try {
it('logAll and logNone works', function () {
var initialLogger = htmx.logger
htmx.logAll();
} finally {
htmx.config.logger = initialLogger;
}
htmx.logger.should.not.equal(null);
htmx.logNone();
should.equal(htmx.logger, null);
htmx.logger = initialLogger;
});
it('eval can be suppressed', function () {

View File

@ -305,6 +305,25 @@ describe("Core htmx Events", function() {
}
});
it("htmx:afterSettle is called multiple times when doing OOB outerHTML swaps", function () {
var called = 0;
var handler = htmx.on("htmx:afterSettle", function (evt) {
called++;
});
try {
this.server.respondWith("POST", "/test", function (xhr) {
xhr.respond(200, {}, "<button>Bar</button>\n <div id='t1' hx-swap-oob='true'>t1</div><div id='t2' hx-swap-oob='true'>t2</div>");
});
var div = make("<button id='button' hx-post='/test' hx-target='#t'>Foo</button><div id='t'></div><div id='t1'></div><div id='t2'></div>");
var button = byId("button")
button.click();
this.server.respond();
should.equal(called, 3);
} finally {
htmx.off("htmx:afterSettle", handler);
}
});
it("htmx:afterRequest is called after a successful request", function () {
var called = false;
var handler = htmx.on("htmx:afterRequest", function (evt) {

View File

@ -177,5 +177,59 @@ describe("Core htmx Parameter Handling", function() {
vals['do'].should.equal('rey');
})
it('it puts GET params in the URL by default', function () {
this.server.respondWith("GET", "/test?i1=value", function (xhr) {
xhr.respond(200, {}, "Clicked!");
});
var form = make('<form hx-trigger="click" hx-get="/test"><input name="i1" value="value"/><button id="b1">Click Me!</button></form>');
form.click();
this.server.respond();
form.innerHTML.should.equal("Clicked!");
});
it('it puts GET params in the body if methodsThatUseUrlParams is empty', function () {
this.server.respondWith("GET", "/test", function (xhr) {
xhr.requestBody.should.equal("i1=value");
xhr.respond(200, {}, "Clicked!");
});
var form = make('<form hx-trigger="click" hx-get="/test"><input name="i1" value="value"/><button id="b1">Click Me!</button></form>');
try {
htmx.config.methodsThatUseUrlParams = [];
form.click();
this.server.respond();
form.innerHTML.should.equal("Clicked!");
} finally {
htmx.config.methodsThatUseUrlParams = ["get"];
}
});
it('it puts DELETE params in the body by default', function () {
this.server.respondWith("DELETE", "/test", function (xhr) {
xhr.requestBody.should.equal("i1=value");
xhr.respond(200, {}, "Clicked!");
});
var form = make('<form hx-trigger="click" hx-delete="/test"><input name="i1" value="value"/><button id="b1">Click Me!</button></form>');
form.click();
this.server.respond();
form.innerHTML.should.equal("Clicked!");
});
it('it puts DELETE params in the URL if methodsThatUseUrlParams contains "delete"', function () {
this.server.respondWith("DELETE", "/test?i1=value", function (xhr) {
xhr.respond(200, {}, "Clicked!");
});
var form = make('<form hx-trigger="click" hx-delete="/test"><input name="i1" value="value"/><button id="b1">Click Me!</button></form>');
try {
htmx.config.methodsThatUseUrlParams.push("delete")
form.click();
this.server.respond();
form.innerHTML.should.equal("Clicked!");
} finally {
htmx.config.methodsThatUseUrlParams = ["get"];
}
});
});

View File

@ -130,7 +130,6 @@ describe("Core htmx Regression Tests", function(){
it('a form can reset based on the htmx:afterRequest event', function() {
this.server.respondWith("POST", "/test", "posted");
//htmx.logAll();
var form = make('<div id="d1"></div><form _="on htmx:afterRequest reset() me" hx-post="/test" hx-target="#d1">' +
' <input type="text" name="input" id="i1"/>' +
@ -174,7 +173,6 @@ describe("Core htmx Regression Tests", function(){
it("supports unset on hx-select", function(){
this.server.respondWith("GET", "/test", "Foo<span id='example'>Bar</span>");
htmx.logAll();
make('<form hx-select="#example">\n' +
' <button id="b1" hx-select="unset" hx-get="/test">Initial</button>\n' +
'</form>')

View File

@ -1,4 +1,3 @@
//
describe("json-enc extension", function() {
beforeEach(function () {
this.server = makeServer();
@ -9,6 +8,15 @@ describe("json-enc extension", function() {
clearWorkArea();
});
it('handles basic get properly', function () {
var jsonResponseBody = JSON.stringify({});
this.server.respondWith("GET", "/test", jsonResponseBody);
var div = make('<div hx-get="/test" hx-ext="json-enc">click me</div>');
div.click();
this.server.respond();
this.server.lastRequest.response.should.equal("{}");
})
it('handles basic post properly', function () {
var jsonResponseBody = JSON.stringify({});
this.server.respondWith("POST", "/test", jsonResponseBody);
@ -67,7 +75,6 @@ describe("json-enc extension", function() {
})
it('handles put with form parameters', function () {
this.server.respondWith("PUT", "/test", function (xhr) {
var values = JSON.parse(xhr.requestBody);
values.should.have.keys("username","password");

View File

@ -77,6 +77,17 @@ describe("web-sockets extension", function () {
this.messages.length.should.equal(1);
})
it('sends data to the server with specific trigger', function () {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div hx-trigger="click" ws-send id="d1">div1</div></div>');
this.tickMock();
byId("d1").click();
this.tickMock();
this.messages.length.should.equal(1);
})
it('handles message from the server', function () {
var div = make('<div hx-ext="ws" ws-connect="ws://localhost:8080"><div id="d1">div1</div><div id="d2">div2</div></div>');
this.tickMock();

View File

@ -67,6 +67,7 @@
<script src="attributes/hx-indicator.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>
<script src="attributes/hx-post.js"></script>

View File

@ -38,7 +38,8 @@ function getWorkArea() {
}
function clearWorkArea() {
getWorkArea().innerHTML = "";
const workArea = getWorkArea();
if (workArea) workArea.innerHTML = "";
}
function removeWhiteSpace(str) {

View File

@ -240,6 +240,16 @@ Log all htmx events, useful for debugging.
htmx.logAll();
```
### Method - `htmx.logNone()` {#logNone}
Log no htmx events, call this to turn off the debugger if you previously enabled it.
##### Example
```js
htmx.logNone();
```
### Property - `htmx.logger` {#logger}
The logger htmx uses to log with

View File

@ -7,7 +7,8 @@ added to it for the duration of the request. This can be used to show spinners o
while the request is in flight.
The value of this attribute is a CSS query selector of the element or elements to apply the class to,
or the keyword `closest`, followed by a CSS selector, which will find the closest matching parent (e.g. `closest tr`);
or the keyword [`closest`](https://developer.mozilla.org/docs/Web/API/Element/closest), followed by a CSS selector,
which will find the closest ancestor element or itself, that matches the given CSS selector (e.g. `closest tr`);
Here is an example with a spinner adjacent to the button:

View File

@ -6,13 +6,54 @@ The `hx-on` attribute allows you to embed scripts inline to respond to events di
`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.
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 one that uses the `hx-on` attribute directly. The
latter form should only be used if IE11 support is required.
### Forms
#### hx-on:* (recommended)
The event name follows a colon `:` in the attribute, and the attribute value is the script to be executed:
```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.
To make writing these a little easier, you can use the shorthand double-colon `hx-on::` for htmx
events, and omit the "htmx" part:
```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>
```
Adding multiple handlers is easy, you just specify additional attributes:
```html
<button hx-get="/info"
hx-on::beforeRequest="alert('Making a request!'")
hx-on::afterRequest="alert('Done making a request!')">
Get Info!
</button>
```
#### hx-on (deprecated, except for IE11 support)
The value is an event name, followed by a colon `:`, followed by the script:
```html
<div hx-on="click: alert('Clicked!')">Click</div>
```
All htmx events can be captured, too!
And htmx events:
```html
<button hx-get="/info" hx-on="htmx:beforeRequest: alert('Making a request!')">
@ -20,6 +61,15 @@ All htmx events can be captured, too!
</button>
```
Multiple handlers can be defined by putting them on new lines:
```html
<button hx-get="/info" hx-on="htmx:beforeRequest: alert('Making a request!')
htmx:afterRequest: alert('Done making a request!')">
Get Info!
</button>
```
### Symbols
Like `onevent`, two symbols are made available to event handler scripts:
@ -27,19 +77,10 @@ Like `onevent`, two symbols are made available to event handler scripts:
* `this` - The element on which the `hx-on` attribute is defined
* `event` - The event that triggered the handler
### Multiple Handlers
Multiple handlers can be defined by putting them on new lines:
```html
<button hx-get="/info" hx-on="htmx:beforeRequest: alert('Making a request!')
htmx:afterRequest: alert('Done making a request!')">
Get Info!
</button>
```
### Notes
* `hx-on` is _not_ inherited, however due to
[event bubbling](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_bubbling_and_capture),
`hx-on` attributes on parent elements will typically be triggered by events on child elements
* `hx-on:*` and `hx-on` cannot be used together on the same element; if `hx-on:*` is present, the value of an `hx-on` attribute
on the same element will be ignored. The two forms can be mixed in the same document, however.

View File

@ -7,7 +7,8 @@ request. The value of this attribute can be:
* A CSS query selector of the element to target.
* `this` which indicates that the element that the `hx-target` attribute is on is the target.
* `closest <CSS selector>` which will find the closest parent ancestor that matches the given CSS selector
* `closest <CSS selector>` which will find the [closest](https://developer.mozilla.org/docs/Web/API/Element/closest)
ancestor element or itself, that matches the given CSS selector
(e.g. `closest tr` will target the closest table row to the element).
* `find <CSS selector>` which will find the first child descendant element that matches the given CSS selector.
* `next <CSS selector>` which will scan the DOM forward for the first element that matches the given CSS selector.

View File

@ -58,7 +58,7 @@ is seen again before the delay completes it is ignored, the element will trigger
* The extended CSS selector here allows for the following non-standard CSS values:
* `document` - listen for events on the document
* `window` - listen for events on the window
* `closest <CSS selector>` - finds the closest parent matching the given css selector
* `closest <CSS selector>` - finds the [closest](https://developer.mozilla.org/docs/Web/API/Element/closest) ancestor element or itself, matching the given css selector
* `find <CSS selector>` - finds the closest child matching the given css selector
* `target:<CSS selector>` - allows you to filter via a CSS selector on the target of the event. This can be useful when you want to listen for
triggers from elements that might not be in the DOM at the point of initialization, by, for example, listening on the body,

View File

@ -392,7 +392,8 @@ input tag.
`hx-target`, and most attributes that take a CSS selector, support an "extended" CSS syntax:
* You can use the `this` keyword, which indicates that the element that the `hx-target` attribute is on is the target
* The `closest <CSS selector>` syntax will find the closest parent ancestor that matches the given CSS selector.
* The `closest <CSS selector>` syntax will find the [closest](https://developer.mozilla.org/docs/Web/API/Element/closest)
ancestor element or itself, that matches the given CSS selector.
(e.g. `closest tr` will target the closest table row to the element)
* The `next <CSS selector>` syntax will find the next element in the DOM matching the given CSS selector.
* The `previous <CSS selector>` syntax will find the previous element in the DOM the given CSS selector.
@ -1252,7 +1253,7 @@ with an `on*` property, but can be done using the `hx-on` attribute:
```html
<button hx-post="/example"
hx-on="htmx:beforeRequest: event.detail.parameters.example = 'Hello Scripting!'">
hx-on="htmx:configRequest: event.detail.parameters.example = 'Hello Scripting!'">
Post Me!
</button>
```
@ -1516,6 +1517,7 @@ listed below:
| `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 this method by encoding their parameters in the URL, not the request body |
</div>

View File

@ -178,7 +178,7 @@ is a form that on submit will change its look to indicate that a request is bein
transition: opacity 300ms linear;
}
</style>
<form hx-post="/name">
<form hx-post="/name" hx-swap="outerHTML">
<label>Name:</label><input name="name"><br/>
<button>Submit</button>
</form>
@ -193,10 +193,12 @@ is a form that on submit will change its look to indicate that a request is bein
}
</style>
<form hx-post="/name">
<div aria-live="polite">
<form hx-post="/name" hx-swap="outerHTML">
<label>Name:</label><input name="name"><br/>
<button>Submit</button>
</form>
</div>
<script>
onPost("/name", function(){ return "Submitted!"; });

View File

@ -9,8 +9,8 @@ values in `PUT`'s to two different endpoints: `activate` and `deactivate`:
```html
<div hx-include="#checked-contacts" hx-target="#tbody">
<a class="btn" hx-put="/activate">Activate</a>
<a class="btn" hx-put="/deactivate">Deactivate</a>
<button class="btn" hx-put="/activate">Activate</button>
<button class="btn" hx-put="/deactivate">Deactivate</button>
</div>
<form id="checked-contacts">
@ -126,7 +126,7 @@ You can see a working example of this code below.
// templates
function displayUI(contacts) {
return `<h3>Select Rows And Activate Or Deactivate Below<h3>
return `<h3>Select Rows And Activate Or Deactivate Below</h3>
<form id="checked-contacts">
<table>
<thead>
@ -145,8 +145,8 @@ You can see a working example of this code below.
<br/>
<br/>
<div hx-include="#checked-contacts" hx-target="#tbody">
<a class="btn" hx-put="/activate">Activate</a>
<a class="btn" hx-put="/deactivate">Deactivate</a>
<button class="btn" hx-put="/activate">Activate</button>
<button class="btn" hx-put="/deactivate">Deactivate</button>
</div>`
}

View File

@ -75,18 +75,18 @@ The click to edit pattern provides a way to offer inline editing of all or part
function formTemplate(contact) {
return `<form hx-put="/contact/1" hx-target="this" hx-swap="outerHTML">
<div>
<label>First Name</label>
<input type="text" name="firstName" value="${contact.firstName}">
<label for="firstName">First Name</label>
<input autofocus type="text" id="firstName" name="firstName" value="${contact.firstName}">
</div>
<div class="form-group">
<label>Last Name</label>
<input type="text" name="lastName" value="${contact.lastName}">
<label for="lastName">Last Name</label>
<input type="text" id="lastName" name="lastName" value="${contact.lastName}">
</div>
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" value="${contact.email}">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="${contact.email}">
</div>
<button class="btn">Submit</button>
<button class="btn" type="submit">Submit</button>
<button class="btn" hx-get="/contact/1">Cancel</button>
</form>`
}

View File

@ -72,7 +72,7 @@ results (which will contain a button to load the *next* page of results). And s
var txt = "";
for (var i = 0; i < contacts.length; i++) {
var c = contacts[i];
txt += "<tr><td>" + c.name + "</td><td>" + c.email + "</td><td>" + c.id + "</td></tr>\n";
txt += `<tr><td>${c.name}</td><td>${c.email}</td><td>${c.id}</td></tr>\n`;
}
txt += loadMoreRow(page);
return txt;

View File

@ -113,28 +113,28 @@ Below is a working demo of this example. The only email that will be accepted i
function formTemplate() {
return `<form hx-post="/contact">
<div hx-target="this" hx-swap="outerHTML">
<label>Email Address</label>
<input name="email" hx-post="/contact/email" hx-indicator="#ind">
<label for="email">Email Address</label>
<input name="email" id="email" hx-post="/contact/email" hx-indicator="#ind">
<img id="ind" src="/img/bars.svg" class="htmx-indicator"/>
</div>
<div class="form-group">
<label>First Name</label>
<input type="text" class="form-control" name="firstName">
<label for="firstName">First Name</label>
<input type="text" class="form-control" name="firstName" id="firstName">
</div>
<div class="form-group">
<label>Last Name</label>
<input type="text" class="form-control" name="lastName">
<label for="lastName">Last Name</label>
<input type="text" class="form-control" name="lastName" id="lastName">
</div>
<button class="btn btn-default" disabled>Submit</button>
<button type='submit' class="btn btn-default" disabled>Submit</button>
</form>`;
}
function emailInputTemplate(val, errorMsg) {
return `<div hx-target="this" hx-swap="outerHTML" class="${errorMsg ? "error" : "valid"}">
<label>Email Address</label>
<input name="email" hx-post="/contact/email" hx-indicator="#ind" value="${val}">
<input name="email" hx-post="/contact/email" hx-indicator="#ind" value="${val}" aria-invalid="${!!errorMsg}">
<img id="ind" src="/img/bars.svg" class="htmx-indicator"/>
${errorMsg ? ("<div class='error-message'>" + errorMsg + "</div>") : ""}
${errorMsg ? (`<div class='error-message' >${errorMsg}</div>`) : ""}
</div>`;
}
</script>

View File

@ -16,37 +16,48 @@ We start with an initial state with a button that issues a `POST` to `/start` to
</div>
```
This div is then replaced with a new div that reloads itself every 600ms:
This div is then replaced with a new div containing status and a progress bar that reloads itself every 600ms:
```html
<div hx-target="this"
hx-get="/job"
hx-trigger="load delay:600ms"
hx-swap="outerHTML">
<h3>Running</h3>
<div class="progress">
<div id="pb" class="progress-bar" style="width:0%"></div>
<div hx-trigger="done" hx-get="/job" hx-swap="outerHTML" hx-target="this">
<h3 role="status" id="pblabel" tabindex="-1" autofocus>Running</h3>
<div
hx-get="/job/progress"
hx-trigger="every 600ms"
hx-target="this"
hx-swap="innerHTML">
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-labelledby="pblabel">
<div id="pb" class="progress-bar" style="width:0%">
</div>
</div>
</div>
```
This HTML is rerendered every 600 milliseconds, with the "width" style attribute on the progress bar being updated.
This progress bar is updated every 600 milliseconds, with the "width" style attribute and `aria-valuenow` attributed set to current progress value.
Because there is an id on the progress bar div, htmx will smoothly transition between requests by settling the
style attribute into its new value. This, when coupled with CSS transitions, make the visual transition continuous
style attribute into its new value. This, when coupled with CSS transitions, makes the visual transition continuous
rather than jumpy.
Finally, when the process is complete, a restart button is added to the UI (we are using the [`class-tools`](@/extensions/class-tools.md)
extension in this example):
Finally, when the process is complete, a server returns `HX-Trigger: done` header, which triggers an update of the UI to "Complete" state
with a restart button added to the UI (we are using the [`class-tools`](@/extensions/class-tools.md) extension in this example to add fade-in effect on the button):
```html
<div hx-target="this"
hx-get="/job"
<div hx-trigger="done" hx-get="/job" hx-swap="outerHTML" hx-target="this">
<h3 role="status" id="pblabel" tabindex="-1" autofocus>Complete</h3>
<div
hx-get="/job/progress"
hx-trigger="none"
hx-swap="outerHTML">
<h3>Complete</h3>
<div class="progress">
<div id="pb" class="progress-bar" style="width:100%"></div>
hx-target="this"
hx-swap="innerHTML">
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="122" aria-labelledby="pblabel">
<div id="pb" class="progress-bar" style="width:122%">
</div>
</div>
</div>
<button id="restart-btn" class="btn" hx-post="/start" classes="add show:600ms">
Restart Job
</button>
@ -136,6 +147,15 @@ This example uses styling cribbed from the bootstrap progress bar:
return jobStatusTemplate(job);
});
onGet("/job/progress", function(request, params, responseHeaders){
var job = jobManager.currentProcess();
if (job.complete) {
responseHeaders["HX-Trigger"] = "done";
}
return jobProgressTemplate(job);
});
// templates
function startButton(message) {
return `<div hx-target="this" hx-swap="outerHTML">
@ -146,22 +166,31 @@ This example uses styling cribbed from the bootstrap progress bar:
</div>`;
}
function jobStatusTemplate(job) {
return `<div hx-target="this"
hx-get="/job"
hx-trigger="${job.complete ? 'none' : 'load delay:600ms'}"
hx-swap="outerHTML">
<h3>${job.complete ? "Complete" : "Running"}</h3>
<div class="progress">
function jobProgressTemplate(job) {
return `<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${job.percentComplete}" aria-labelledby="pblabel">
<div id="pb" class="progress-bar" style="width:${job.percentComplete}%">
</div>
</div>`
}
function jobStatusTemplate(job) {
return `<div hx-trigger="done" hx-get="/job" hx-swap="outerHTML" hx-target="this">
<h3 role="status" id="pblabel" tabindex="-1" autofocus>${job.complete ? "Complete" : "Running"}</h3>
<div
hx-get="/job/progress"
hx-trigger="${job.complete ? 'none' : 'every 600ms'}"
hx-target="this"
hx-swap="innerHTML">
${jobProgressTemplate(job)}
</div>
${restartButton(job)}`;
}
function restartButton(job) {
if(job.complete){
return `<button id="restart-btn" class="btn" hx-post="/start" classes="add show:600ms">
return `
<button id="restart-btn" class="btn" hx-post="/start" classes="add show:600ms">
Restart Job
</button>`
} else {

View File

@ -15,13 +15,13 @@ The main page simply includes the following HTML to load the initial tab into th
Subsequent tab pages display all tabs and highlight the selected one accordingly.
```html
<div class="tab-list">
<a hx-get="/tab1" class="selected">Tab 1</a>
<a hx-get="/tab2">Tab 2</a>
<a hx-get="/tab3">Tab 3</a>
<div class="tab-list" role="tablist">
<button hx-get="/tab1" class="selected" role="tab" aria-selected="false" aria-controls="tab-content">Tab 1</button>
<button hx-get="/tab2" role="tab" aria-selected="false" aria-controls="tab-content">Tab 2</button>
<button hx-get="/tab3" role="tab" aria-selected="false" aria-controls="tab-content">Tab 3</button>
</div>
<div class="tab-content">
<div id="tab-content" role="tabpanel" class="tab-content">
Commodo normcore truffaut VHS duis gluten-free keffiyeh iPhone taxidermy godard ramps anim pour-over.
Pitchfork vegan mollit umami quinoa aute aliquip kinfolk eiusmod live-edge cardigan ipsum locavore.
Polaroid duis occaecat narwhal small batch food truck.
@ -33,19 +33,33 @@ Subsequent tab pages display all tabs and highlight the selected one accordingly
{{ demoenv() }}
<div id="tabs" hx-get="/tab1" hx-trigger="load delay:100ms" hx-target="#tabs" hx-swap="innerHTML"></div>
<div id="tabs" hx-target="this" hx-swap="innerHTML">
<div class="tab-list" role="tablist">
<button hx-get="/tab1" class="selected" role="tab" aria-selected="true" aria-controls="tab-content">Tab 1</button>
<button hx-get="/tab2" role="tab" aria-selected="false" aria-controls="tab-content">Tab 2</button>
<button hx-get="/tab3" role="tab" aria-selected="false" aria-controls="tab-content">Tab 3</button>
</div>
<div id="tab-content" role="tabpanel" class="tab-content">
Commodo normcore truffaut VHS duis gluten-free keffiyeh iPhone taxidermy godard ramps anim pour-over.
Pitchfork vegan mollit umami quinoa aute aliquip kinfolk eiusmod live-edge cardigan ipsum locavore.
Polaroid duis occaecat narwhal small batch food truck.
PBR&B venmo shaman small batch you probably haven't heard of them hot chicken readymade.
Enim tousled cliche woke, typewriter single-origin coffee hella culpa.
Art party readymade 90's, asymmetrical hell of fingerstache ipsum.
</div>
</div>
<script>
onGet("/tab1", function() {
return `
<div class="tab-list">
<a hx-get="/tab1" class="selected">Tab 1</a>
<a hx-get="/tab2">Tab 2</a>
<a hx-get="/tab3">Tab 3</a>
<div class="tab-list" role="tablist">
<button hx-get="/tab1" class="selected" aria-selected="true" autofocus role="tab" aria-controls="tab-content">Tab 1</button>
<button hx-get="/tab2" role="tab" aria-selected="false" aria-controls="tab-content">Tab 2</button>
<button hx-get="/tab3" role="tab" aria-selected="false" aria-controls="tab-content">Tab 3</button>
</div>
<div class="tab-content">
<div id="tab-content" role="tabpanel" class="tab-content">
Commodo normcore truffaut VHS duis gluten-free keffiyeh iPhone taxidermy godard ramps anim pour-over.
Pitchfork vegan mollit umami quinoa aute aliquip kinfolk eiusmod live-edge cardigan ipsum locavore.
Polaroid duis occaecat narwhal small batch food truck.
@ -57,13 +71,13 @@ Subsequent tab pages display all tabs and highlight the selected one accordingly
onGet("/tab2", function() {
return `
<div class="tab-list">
<a hx-get="/tab1">Tab 1</a>
<a hx-get="/tab2" class="selected">Tab 2</a>
<a hx-get="/tab3">Tab 3</a>
<div class="tab-list" role="tablist">
<button hx-get="/tab1" role="tab" aria-selected="false" aria-controls="tab-content">Tab 1</button>
<button hx-get="/tab2" class="selected" aria-selected="true" autofocus role="tab" aria-controls="tab-content">Tab 2</button>
<button hx-get="/tab3" role="tab" aria-selected="false" aria-controls="tab-content">Tab 3</button>
</div>
<div class="tab-content">
<div id="tab-content" role="tabpanel" class="tab-content">
Kitsch fanny pack yr, farm-to-table cardigan cillum commodo reprehenderit plaid dolore cronut meditation.
Tattooed polaroid veniam, anim id cornhole hashtag sed forage.
Microdosing pug kitsch enim, kombucha pour-over sed irony forage live-edge.
@ -76,13 +90,13 @@ Subsequent tab pages display all tabs and highlight the selected one accordingly
onGet("/tab3", function() {
return `
<div class="tab-list">
<a hx-get="/tab1">Tab 1</a>
<a hx-get="/tab2">Tab 2</a>
<a hx-get="/tab3" class="selected">Tab 3</a>
<div class="tab-list" role="tablist">
<button hx-get="/tab1" role="tab" aria-selected="false" aria-controls="tab-content">Tab 1</button>
<button hx-get="/tab2" role="tab" aria-selected="false" aria-controls="tab-content">Tab 2</button>
<button hx-get="/tab3" class="selected" aria-selected="true" autofocus role="tab" aria-controls="tab-content">Tab 3</button>
</div>
<div class="tab-content">
<div id="tab-content" role="tabpanel" class="tab-content">
Aute chia marfa echo park tote bag hammock mollit artisan listicle direct trade.
Raw denim flexitarian eu godard etsy.
Poke tbh la croix put a bird on it fixie polaroid aute cred air plant four loko gastropub swag non brunch.
@ -101,13 +115,19 @@ Subsequent tab pages display all tabs and highlight the selected one accordingly
border-bottom: solid 3px #eee;
}
#tabs > .tab-list a {
#tabs > .tab-list button {
border: none;
display: inline-block;
padding: 5px 10px;
cursor:pointer;
background-color: transparent;
}
#tabs > .tab-list a.selected {
#tabs > .tab-list button:hover {
color: var(--midBlue);
}
#tabs > .tab-list button.selected {
background-color: #eee;
}

View File

@ -12,24 +12,24 @@ You may also consider [a more idiomatic approach](@/examples/tabs-hateoas.md) th
The HTML below displays a list of tabs, with added HTMX to dynamically load each tab pane from the server. A simple [hyperscript](https://hyperscript.org) event handler uses the [`take` command](https://hyperscript.org/commands/take/) to switch the selected tab when the content is swapped into the DOM. Alternatively, this could be accomplished with a slightly longer Javascript event handler.
```html
<div id="tabs" hx-target="#tab-contents" _="on htmx:afterOnLoad take .selected for event.target">
<a hx-get="/tab1" class="selected">Tab 1</a>
<a hx-get="/tab2">Tab 2</a>
<a hx-get="/tab3">Tab 3</a>
<div id="tabs" hx-target="#tab-contents" role="tablist" _="on htmx:afterOnLoad set @aria-selected of <[aria-selected=true]/> to false tell the target take .selected set @aria-selected to true">
<button role="tab" aria-controls="tab-content" aria-selected="true" hx-get="/tab1" class="selected">Tab 1</button>
<button role="tab" aria-controls="tab-content" aria-selected="false" hx-get="/tab2">Tab 2</button>
<button role="tab" aria-controls="tab-content" aria-selected="false" hx-get="/tab3">Tab 3</button>
</div>
<div id="tab-contents" hx-get="/tab1" hx-trigger="load"></div>
<div id="tab-contents" role="tabpanel" hx-get="/tab1" hx-trigger="load"></div>
```
{{ demoenv() }}
<div id="tabs" hx-target="#tab-contents" _="on click take .selected for event.target">
<a hx-get="/tab1" class="selected">Tab 1</a>
<a hx-get="/tab2">Tab 2</a>
<a hx-get="/tab3">Tab 3</a>
<div id="tabs" hx-target="#tab-contents" role="tablist" _="on htmx:afterOnLoad set @aria-selected of <[aria-selected=true]/> to false tell the target take .selected set @aria-selected to true">
<button role="tab" aria-controls="tab-content" aria-selected="true" hx-get="/tab1" class="selected">Tab 1</button>
<button role="tab" aria-controls="tab-content" aria-selected="false" hx-get="/tab2">Tab 2</button>
<button role="tab" aria-controls="tab-content" aria-selected="false" hx-get="/tab3">Tab 3</button>
</div>
<div id="tab-contents" hx-get="/tab1" hx-trigger="load"></div>
<div id="tab-contents" role="tabpanel" hx-get="/tab1" hx-trigger="load"></div>
<script src="https://unpkg.com/hyperscript.org"></script>
<script>
@ -42,7 +42,6 @@ The HTML below displays a list of tabs, with added HTMX to dynamically load each
Enim tousled cliche woke, typewriter single-origin coffee hella culpa.
Art party readymade 90's, asymmetrical hell of fingerstache ipsum.</p>
`});
onGet("/tab2", function() {
return `
<p>Kitsch fanny pack yr, farm-to-table cardigan cillum commodo reprehenderit plaid dolore cronut meditation.
@ -54,7 +53,6 @@ The HTML below displays a list of tabs, with added HTMX to dynamically load each
Prism street art cray salvia.</p>
`
});
onGet("/tab3", function() {
return `
<p>Aute chia marfa echo park tote bag hammock mollit artisan listicle direct trade.
@ -76,13 +74,19 @@ The HTML below displays a list of tabs, with added HTMX to dynamically load each
border-bottom: solid 3px #eee;
}
#tabs > a {
#tabs > button {
border: none;
display: inline-block;
padding: 5px 10px;
cursor:pointer;
background-color: transparent;
}
#tabs > a.selected {
#tabs > button:hover {
color: var(--midBlue);
}
#tabs > button.selected {
background-color: #eee;
}

View File

@ -59,6 +59,9 @@ function params(request) {
return parseParams(request.requestBody);
}
}
function headers(request) {
return request.getAllResponseHeaders().split("\r\n").filter(h => h.toLowerCase().startsWith("hx-")).map(h => h.split(": ")).reduce((acc, v) => ({ ...acc, [v[0]]: v[1] }), {})
}
//====================================
// Routing
@ -76,29 +79,33 @@ function init(path, response) {
function onGet(path, response) {
server.respondWith("GET", path, function (request) {
let body = response(request, params(request));
request.respond(200, {}, body);
let headers = {};
let body = response(request, params(request), headers);
request.respond(200, headers, body);
});
}
function onPut(path, response) {
server.respondWith("PUT", path, function (request) {
let body = response(request, params(request));
request.respond(200, {}, body);
let headers = {};
let body = response(request, params(request), headers);
request.respond(200, headers, body);
});
}
function onPost(path, response) {
server.respondWith("POST", path, function (request) {
let body = response(request, params(request));
request.respond(200, {}, body);
let headers = {};
let body = response(request, params(request), headers);
request.respond(200, headers, body);
});
}
function onDelete(path, response) {
server.respondWith("DELETE", path, function (request) {
let body = response(request, params(request));
request.respond(200, {}, body);
let headers = {};
let body = response(request, params(request), headers);
request.respond(200, headers, body);
});
}
@ -173,6 +180,9 @@ function demoResponseTemplate(details){
<div>
parameters: ${JSON.stringify(params(details.xhr))}
</div>
<div>
headers: ${JSON.stringify(headers(details.xhr))}
</div>
<div>
<b>Response</b>
<pre class="language-html"><code class="language-html">${escapeHtml(details.xhr.response)}</code> </pre>