diff --git a/editors/jetbrains/htmx.web-types.json b/editors/jetbrains/htmx.web-types.json index 5694760e..58245a07 100644 --- a/editors/jetbrains/htmx.web-types.json +++ b/editors/jetbrains/htmx.web-types.json @@ -193,7 +193,7 @@ }, { "name": "hx-swap", - "description": "The **hx-swap** attribute controls how the response content is swapped into the DOM (e.g. 'outerHTML' or 'beforeEnd')", + "description": "The **hx-swap** attribute controls how the response content is swapped into the DOM (e.g. 'outerHTML' or 'beforeend')", "description-sections": { "Inherited": "" }, diff --git a/src/ext/response-targets.js b/src/ext/response-targets.js new file mode 100644 index 00000000..16e9d740 --- /dev/null +++ b/src/ext/response-targets.js @@ -0,0 +1,110 @@ +(function(){ + + /** @type {import("../htmx").HtmxInternalApi} */ + var api; + + const targetAttrPrefix = 'hx-target-'; + const targetAttrMinLen = targetAttrPrefix.length - 1; + + /** + * @param {HTMLElement} elt + * @param {number} respCode + * @returns {HTMLElement | null} + */ + function getRespCodeTarget(elt, respCode) { + if (!elt || !respCode) return null; + + var targetAttr = targetAttrPrefix + respCode; + var targetStr = api.getClosestAttributeValue(elt, targetAttr); + + if (targetStr) { + if (targetStr === "this") { + return api.findThisElement(elt, targetAttr); + } else { + return api.querySelectorExt(elt, targetStr); + } + } else { + for (let l = targetAttr.length - 1; l > targetAttrMinLen; l--) { + targetAttr = targetAttr.substring(0, l) + '*'; + targetStr = api.getClosestAttributeValue(elt, targetAttr); + if (targetStr) break; + } + } + + if (targetStr) { + if (targetStr === "this") { + return api.findThisElement(elt, targetAttr); + } else { + return api.querySelectorExt(elt, targetStr); + } + } else { + return null; + } + } + + /** @param {Event} evt */ + function handleErrorFlag(evt) { + if (evt.detail.isError) { + if (htmx.config.responseTargetUnsetsError) { + evt.detail.isError = false; + } + } else if (htmx.config.responseTargetSetsError) { + evt.detail.isError = true; + } + } + + htmx.defineExtension('response-targets', { + + /** @param {import("../htmx").HtmxInternalApi} apiRef */ + init: function (apiRef) { + api = apiRef; + + if (htmx.config.responseTargetUnsetsError === undefined) { + htmx.config.responseTargetUnsetsError = true; + } + if (htmx.config.responseTargetSetsError === undefined) { + htmx.config.responseTargetSetsError = false; + } + if (htmx.config.responseTargetPrefersExisting === undefined) { + htmx.config.responseTargetPrefersExisting = false; + } + if (htmx.config.responseTargetPrefersRetargetHeader === undefined) { + htmx.config.responseTargetPrefersRetargetHeader = true; + } + }, + + /** + * @param {string} name + * @param {Event} evt + */ + onEvent: function (name, evt) { + if (name === "htmx:beforeSwap" && + evt.detail.xhr && + evt.detail.xhr.status !== 200) { + if (evt.detail.target) { + if (htmx.config.responseTargetPrefersExisting) { + evt.detail.shoudSwap = true; + handleErrorFlag(evt); + return true; + } + if (htmx.config.responseTargetPrefersRetargetHeader && + evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) { + evt.detail.shouldSwap = true; + handleErrorFlag(evt); + return true; + } + } + if (!evt.detail.requestConfig) { + return true; + } + var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status); + if (target) { + handleErrorFlag(evt); + evt.detail.shouldSwap = true; + evt.detail.target = target; + } + return true; + } + } + }); +})(); diff --git a/src/htmx.d.ts b/src/htmx.d.ts index 1090aa81..4b0fab7a 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -45,7 +45,7 @@ export function ajax(verb: string, path: string, selector: string): void; export function ajax( verb: string, path: string, - context: Partial<{ source: any; event: any; handler: any; target: any; values: any; headers: any }> + context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any }> ): void; /** @@ -290,6 +290,8 @@ export const version: string; export interface HtmxConfig { /** array of strings: the attributes to settle during the settling phase */ attributesToSettle?: ["class", "style", "width", "height"] | string[]; + /** if the focused element should be scrolled into view */ + defaultFocusScroll?: boolean; /** the default delay between completing the content swap and settling attributes */ defaultSettleDelay?: number; /** the default delay between receiving a response from the server and doing the swap */ @@ -324,7 +326,7 @@ export interface HtmxConfig { refreshOnHistoryMiss?: boolean; timeout?: number; disableSelector?: "[hx-disable], [data-hx-disable]" | string; - scrollBehavior?: "smooth"; + scrollBehavior?: "smooth" | "auto"; } /** diff --git a/src/htmx.js b/src/htmx.js index c7ea3f45..c7b79efe 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -90,9 +90,11 @@ return (function () { addTriggerHandler: addTriggerHandler, bodyContains: bodyContains, canAccessLocalStorage: canAccessLocalStorage, + findThisElement: findThisElement, filterValues: filterValues, hasAttribute: hasAttribute, getAttributeValue: getAttributeValue, + getClosestAttributeValue: getClosestAttributeValue, getClosestMatch: getClosestMatch, getExpressionVars: getExpressionVars, getHeaders: getHeaders, @@ -105,6 +107,7 @@ return (function () { mergeObjects: mergeObjects, makeSettleInfo: makeSettleInfo, oobSwap: oobSwap, + querySelectorExt: querySelectorExt, selectAndSwap: selectAndSwap, settleImmediately: settleImmediately, shouldCancel: shouldCancel, @@ -2196,7 +2199,9 @@ return (function () { swapInnerHTML(historyElement, fragment, settleInfo) settleImmediately(settleInfo.tasks); document.title = cached.title; - window.scrollTo(0, cached.scroll); + setTimeout(function () { + window.scrollTo(0, cached.scroll); + }, 0); // next 'tick', so browser has time to render layout currentPathForHistory = path; triggerEvent(getDocument().body, "htmx:historyRestore", {path:path, item:cached}); } else { diff --git a/test/ext/response-targets.js b/test/ext/response-targets.js new file mode 100644 index 00000000..4866f96f --- /dev/null +++ b/test/ext/response-targets.js @@ -0,0 +1,224 @@ +describe("response-targets extension", function() { + beforeEach(function() { + this.server = sinon.fakeServer.create(); + clearWorkArea(); + }); + afterEach(function() { + this.server.restore(); + clearWorkArea(); + }); + + it('targets an adjacent element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = make('
') + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets an adjacent element properly with wildcard', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = make('
') + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a parent element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a parent element properly with wildcard', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `this` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `closest` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('

') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `closest` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('

') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `find` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
Click Me!
') + div1.click(); + this.server.respond(); + var span1 = byId("s1") + var span2 = byId("s2") + span1.innerHTML.should.equal("Not found!"); + span2.innerHTML.should.equal(""); + }); + + it('targets a `find` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
Click Me!
') + div1.click(); + this.server.respond(); + var span1 = byId("s1") + var span2 = byId("s2") + span1.innerHTML.should.equal("Not found!"); + span2.innerHTML.should.equal(""); + }); + + it('targets an inner element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = byId("d1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets an inner element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = byId("d1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('handles bad target gracefully', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + btn.click(); + this.server.respond(); + btn.innerHTML.should.equal("Click Me!"); + }); + + + it('targets an adjacent element properly w/ data-* prefix', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = make('
') + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `next` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal(""); + }); + + it('targets a `next` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal(""); + }); + + it('targets a `previous` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal(""); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal("Not found!"); + }); + + it('targets a `previous` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal(""); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal("Not found!"); + }); +}); diff --git a/test/index.html b/test/index.html index cf5d8518..f77af57c 100644 --- a/test/index.html +++ b/test/index.html @@ -143,6 +143,9 @@ + + + diff --git a/www/content/essays/_index.md b/www/content/essays/_index.md index 3902e0b4..c5812f90 100644 --- a/www/content/essays/_index.md +++ b/www/content/essays/_index.md @@ -56,6 +56,7 @@ insert_anchor_links = "left" + diff --git a/www/content/examples/modal-custom.md b/www/content/examples/modal-custom.md index c82842f8..367c0d35 100644 --- a/www/content/examples/modal-custom.md +++ b/www/content/examples/modal-custom.md @@ -101,7 +101,7 @@ We'll define some nice animations in CSS, and use some Hyperscript events (or al } #modal.closing > .modal-content { - /* Aniate when closing */ + /* Animate when closing */ animation-name: zoomOut; animation-duration:150ms; animation-timing-function: ease; @@ -218,7 +218,7 @@ We'll define some nice animations in CSS, and use some Hyperscript events (or al } #modal.closing > .modal-content { - /* Aniate when closing */ + /* Animate when closing */ animation-name: zoomOut; animation-duration:150ms; animation-timing-function: ease; diff --git a/www/content/extensions/_index.md b/www/content/extensions/_index.md index ec0da34b..5b8f9dc7 100644 --- a/www/content/extensions/_index.md +++ b/www/content/extensions/_index.md @@ -79,6 +79,7 @@ See the individual extension documentation for more details. | [`path-deps`](@/extensions/path-deps.md) | an extension for expressing path-based dependencies [similar to intercoolerjs](http://intercoolerjs.org/docs.html#dependencies) | [`preload`](@/extensions/preload.md) | preloads selected `href` and `hx-get` targets based on rules you control. | [`remove-me`](@/extensions/remove-me.md) | allows you to remove an element after a given amount of time +| [`response-targets`](@/extensions/response-targets.md) | allows to specify different target elements to be swapped when different HTTP response codes are received | [`restored`](@/extensions/restored.md) | allows you to trigger events when the back button has been pressed | [`server-sent-events`](@/extensions/server-sent-events.md) | uni-directional server push messaging via [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) | [`web-sockets`](@/extensions/web-sockets.md) | bi-directional connection to WebSocket servers diff --git a/www/content/extensions/response-targets.md b/www/content/extensions/response-targets.md new file mode 100644 index 00000000..796e7fd2 --- /dev/null +++ b/www/content/extensions/response-targets.md @@ -0,0 +1,117 @@ ++++ +title = "response-targets" ++++ + +This extension allows to specify different target elements to be swapped when +different HTTP response codes are received. + +It uses attribute names in a form of ``hx-target-[CODE]`` where `[CODE]` is a numeric +HTTP response code with the optional wildcard character at its end. + +The value of each 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 ` which will find the closest parent ancestor that matches the given CSS selector + (e.g. `closest tr` will target the closest table row to the element). +* `find ` which will find the first child descendant element that matches the given CSS selector. +* `next ` which will scan the DOM forward for the first element that matches the given CSS selector. + (e.g. `next .error` will target the closest following sibling element with `error` class) +* `previous ` which will scan the DOM backwards for the first element that matches the given CSS selector. + (e.g `previous .error` will target the closest previous sibling with `error` class) + +## Install + +```html + +``` + +## Configure (optional) + +* When `HX-Retarget` response header is received it disables any lookup that would be + performed by this extension but any responses with error status codes will be + swapped (normally they would not be, even with target set via header) and internal + error flag (`isError`) will be modified. You may change this and choose to ignore + `HX-Retarget` header when `hx-target-…` is in place by setting a configuration flag + `htmx.config.responseTargetPrefersRetargetHeader` to `false` (default is + `true`). Note that this extension only performs a simple check whether the header + is set and target exists. It is not extracting target's value from the header but + trusts it was set by HTMX core logic. + +* Normally, any target which is already established by HTMX built-in functions or + extensions called before will be overwritten if a matching `hx-target-…` tag is + found. You may change it by using a configuration flag + `htmx.config.responseTargetPrefersExisting` to `true` (default is `false`). This is + kinky and risky option. It has a real-life applications similar to a skilled, + full-stack tardigrade eating parentheses when no one is watching. + +* `isError` flag on the `detail` member of an event associated with swapping the + content with `hx-target-[CODE]` will be set to `false` when error response code is + received. This is different from the default behavior. You may change this by + setting a configuration flag `htmx.config.responseTargetUnsetsError` to `false` + (default is `true`). + +* `isError` flag on the `detail` member of an event associated with swapping the + content with `hx-target-[CODE]` will be set to `false` when non-erroneous response + code is received. This is no different from the default behavior. You may change + this by setting a configuration flag `htmx.config.responseTargetSetsError` to + `true` (default is `false`). This setting will not affect the response code 200 + since it is not handled by this extension. + +## Usage + +Here is an example that targets a `div` for normal (200) response but another `div` +for 404 (not found) response, and yet another for all 5xx response codes: + +```html +
+
+ +
+
+
+``` + +* The response from the `/register` URL will replace contents of the `div` with the + `id` `response-div` when response code is 200 (OK). + +* The response from the `/register` URL will replace contents of the `div` with the `id` + `serious-errors` when response code begins with a digit 5 (server errors). + +* The response from the `/register` URL will will replace contents of the `div` with + the `id` `not-found` when response code is 404 (Not Found). + +## Wildcard resolution + +When status response code does not match existing `hx-target-[CODE]` attribute name +then its numeric part expressed as a string is trimmed with last character being +replaced with the asterisk (`*`). This lookup process continues until the attribute +is found or there are no more digits. + +For example, if a browser receives 404 error code, the following attribute names will +be looked up (in the given order): + +* `hx-target-404` +* `hx-target-40*` +* `hx-target-4*` +* `hx-target-*`. + +## Notes + +* `hx-target-…` is inherited and can be placed on a parent element. +* `hx-target-…` cannot be used to handle HTTP response code 200. +* `hx-target-…` will honor `HX-Retarget` by default and will prefer it over any + calculated target but it can be changed by disabling the + `htmx.config.responseTargetPrefersRetargetHeader` configuration option. +* To avoid surprises the `hx-ext` attribute used to enable this extension should be + placed on a parent element containing elements with `hx-target-…` and `hx-target` + attributes. + +## See also + +* [`hx-target`](@/attributes/hx-target.md), specifies the target element to be swapped diff --git a/www/content/reference.md b/www/content/reference.md index 3dfbe4ee..b8d77f4e 100644 --- a/www/content/reference.md +++ b/www/content/reference.md @@ -28,7 +28,7 @@ The following are the most common attributes when using htmx. | [`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) -| [`hx-swap`](@/attributes/hx-swap.md) | controls how content is swapped in (`outerHTML`, `beforeEnd`, `afterend`, ...) +| [`hx-swap`](@/attributes/hx-swap.md) | controls how content is swapped in (`outerHTML`, `beforeend`, `afterend`, ...) | [`hx-swap-oob`](@/attributes/hx-swap-oob.md) | marks content in a response to be out of band (should swap in somewhere other than the target) | [`hx-target`](@/attributes/hx-target.md) | specifies the target element to be swapped | [`hx-trigger`](@/attributes/hx-trigger.md) | specifies the event that triggers the request @@ -44,7 +44,7 @@ The table below lists all other attributes available in htmx. | Attribute | Description | |----------------------------------------------------|-------------| -| [`hx-confirm`](@/attributes/hx-confirm.md) | shows a `confim()` dialog before issuing a request +| [`hx-confirm`](@/attributes/hx-confirm.md) | shows a `confirm()` dialog before issuing a request | [`hx-delete`](@/attributes/hx-delete.md) | issues a `DELETE` to the specified URL | [`hx-disable`](@/attributes/hx-disable.md) | disables htmx processing for the given node and any children nodes | [`hx-disinherit`](@/attributes/hx-disinherit.md) | control and disable automatic attribute inheritance for child nodes @@ -151,7 +151,7 @@ The table below lists all other attributes available in htmx. | [`htmx:load`](@/events.md#htmx:load) | triggered when new content is added to the DOM | [`htmx:noSSESourceError`](@/events.md#htmx:noSSESourceError) | triggered when an element refers to a SSE event in its trigger, but no parent SSE source has been defined | [`htmx:onLoadError`](@/events.md#htmx:onLoadError) | triggered when an exception occurs during the onLoad handling in htmx -| [`htmx:oobAfterSwap`](@/events.md#htmx:oobAfterSwap) | triggered after an of band element as been swapped in +| [`htmx:oobAfterSwap`](@/events.md#htmx:oobAfterSwap) | triggered after an out of band element as been swapped in | [`htmx:oobBeforeSwap`](@/events.md#htmx:oobBeforeSwap) | triggered before an out of band element swap is done, allows you to configure the swap | [`htmx:oobErrorNoTarget`](@/events.md#htmx:oobErrorNoTarget) | triggered when an out of band element does not have a matching ID in the current DOM | [`htmx:prompt`](@/events.md#htmx:prompt) | triggered after a prompt is shown diff --git a/www/content/server-examples.md b/www/content/server-examples.md index 65628251..412fae39 100644 --- a/www/content/server-examples.md +++ b/www/content/server-examples.md @@ -79,6 +79,7 @@ These examples may make it a bit easier to get started using htmx with your plat - - - +- ## Prolog @@ -120,4 +121,10 @@ These examples may make it a bit easier to get started using htmx with your plat ### CodeIgniter 4 -- \ No newline at end of file +- + +## Elixir + +### Phoenix + +- diff --git a/www/static/img/memes/istudiedhtml.png b/www/static/img/memes/istudiedhtml.png new file mode 100644 index 00000000..bac96646 Binary files /dev/null and b/www/static/img/memes/istudiedhtml.png differ