From 152ebf4727f94d158e82710e010deea632c96d19 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Thu, 3 Jul 2025 16:12:26 +1200 Subject: [PATCH 01/14] Add hx-swap-modifiers --- src/htmx.js | 198 +++++++++++++++--------- test/attributes/hx-select-oob.js | 43 +++++ test/attributes/hx-select.js | 9 ++ test/attributes/hx-swap-oob.js | 103 ++++++++++++ test/attributes/hx-swap.js | 15 ++ www/content/attributes/hx-select-oob.md | 3 +- www/content/attributes/hx-swap-oob.md | 97 +++++++++--- www/content/attributes/hx-swap.md | 16 ++ www/content/events.md | 1 + 9 files changed, 389 insertions(+), 96 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 4c8203ef2..702e52954 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1460,14 +1460,19 @@ var htmx = (function() { if (oobValue === 'true') { // do nothing } else if (oobValue.indexOf(':') > 0) { - swapStyle = oobValue.substring(0, oobValue.indexOf(':')) - selector = oobValue.substring(oobValue.indexOf(':') + 1) + swapStyle = oobValue.substring(0, oobValue.lastIndexOf(':')) + if (WHITESPACE.test(swapStyle)) { + swapStyle = oobValue // if whitespace then treat whole oobValue as a full swap spec with retarget: or other modifiers + } else { + selector = oobValue.substring(oobValue.lastIndexOf(':') + 1) // otherwise treat anything after : as selector for old format + } } else { swapStyle = oobValue } + var swapSpec = getSwapSpecification(oobElement, swapStyle, { target: selector }) + selector = swapSpec.target oobElement.removeAttribute('hx-swap-oob') oobElement.removeAttribute('data-hx-swap-oob') - const targets = querySelectorAllExt(rootNode, selector, false) if (targets.length) { forEach( @@ -1475,24 +1480,32 @@ var htmx = (function() { function(target) { let fragment const oobElementClone = oobElement.cloneNode(true) - fragment = getDocument().createDocumentFragment() - fragment.appendChild(oobElementClone) - if (!isInlineSwap(swapStyle, target)) { - fragment = asParentNode(oobElementClone) // if this is not an inline swap, we use the content of the node, not the node itself + if (swapSpec.strip === undefined && !isInlineSwap(swapSpec.swapStyle, target)) { + swapSpec.strip = true + } + if (swapSpec.strip) { + // @ts-ignore if elt is template, content will be valid so use as inner content + fragment = oobElementClone.content || asParentNode(oobElementClone) // if this is not an inline swap, we use the content of the node, not the node itself + swapSpec.strip = undefined + } else { + fragment = getDocument().createDocumentFragment() + fragment.appendChild(oobElementClone) } - const beforeSwapDetails = { shouldSwap: true, target, fragment } + const beforeSwapDetails = { shouldSwap: true, target, fragment, swapSpec } if (!triggerEvent(target, 'htmx:oobBeforeSwap', beforeSwapDetails)) return target = beforeSwapDetails.target // allow re-targeting if (beforeSwapDetails.shouldSwap) { - handlePreservedElements(fragment) - swapWithStyle(swapStyle, target, target, fragment, settleInfo) - restorePreservedElements() + swap(target, fragment, beforeSwapDetails.swapSpec, { + contextElement: target, + afterSwapCallback: function(settleInfo) { + forEach(settleInfo.elts, function(elt) { + triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails) + }) + } + }, settleInfo) } - forEach(settleInfo.elts, function(elt) { - triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails) - }) } ) oobElement.parentNode.removeChild(oobElement) @@ -1840,7 +1853,7 @@ var htmx = (function() { } /** - * @param {DocumentFragment} fragment + * @param {DocumentFragment|ParentNode} fragment * @param {HtmxSettleInfo} settleInfo * @param {Node|Document} [rootNode] */ @@ -1864,11 +1877,12 @@ var htmx = (function() { * Implements complete swapping pipeline, including: delay, view transitions, focus and selection preservation, * title updates, scroll, OOB swapping, normal swapping and settling * @param {string|Element} target - * @param {string} content + * @param {string|ParentNode} content * @param {HtmxSwapSpecification} swapSpec * @param {SwapOptions} [swapOptions] + * @param {HtmxSettleInfo} [oobSettleInfo] */ - function swap(target, content, swapSpec, swapOptions) { + function swap(target, content, swapSpec, swapOptions, oobSettleInfo) { if (!swapOptions) { swapOptions = {} } @@ -1876,6 +1890,8 @@ var htmx = (function() { let settleResolve = null let settleReject = null + const isOOBSwap = !!oobSettleInfo // calls passing oobSettleInfo are from oobSwap function and can skip some logic + let doSwap = function() { maybeCall(swapOptions.beforeSwapCallback) @@ -1892,45 +1908,54 @@ var htmx = (function() { // @ts-ignore end: activeElt ? activeElt.selectionEnd : null } - const settleInfo = makeSettleInfo(target) + if (swapSpec.settleDelay !== undefined || swapSpec.swapDelay != undefined) { + oobSettleInfo = undefined // for oobSwaps with swap or settle modifier make and perform own settleInfo + } + const settleInfo = oobSettleInfo || makeSettleInfo(target) // For text content swaps, don't parse the response as HTML, just insert it if (swapSpec.swapStyle === 'textContent') { - target.textContent = content + target.textContent = typeof content === 'string' ? content : content.textContent // Otherwise, make the fragment and process it } else { - let fragment = makeFragment(content) + /** @type DocumentFragment|ParentNode */ + let fragment = typeof content === 'string' ? makeFragment(content) : content + // @ts-ignore if fragment is a ParentNode title will be undefined which is fine settleInfo.title = swapOptions.title || fragment.title if (swapOptions.historyRequest) { - // @ts-ignore fragment can be a parentNode Element fragment = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment } - // select-oob swaps - if (swapOptions.selectOOB) { - const oobSelectValues = swapOptions.selectOOB.split(',') - for (let i = 0; i < oobSelectValues.length; i++) { - const oobSelectValue = oobSelectValues[i].split(':', 2) - let id = oobSelectValue[0].trim() - if (id.indexOf('#') === 0) { - id = id.substring(1) - } - const oobValue = oobSelectValue[1] || 'true' - const oobElement = fragment.querySelector('#' + id) - if (oobElement) { - oobSwap(oobValue, oobElement, settleInfo, rootNode) + if (!isOOBSwap) { + // select-oob swaps + if (swapOptions.selectOOB) { + const oobSelectValues = swapOptions.selectOOB.split(',') + for (let i = 0; i < oobSelectValues.length; i++) { + const oobSelectValue = oobSelectValues[i].split(':') + const selector = oobSelectValue.shift().trim() + const oobValue = oobSelectValue.length > 0 ? oobSelectValue.join(':') : 'true' + let oobElement + if (selector.indexOf('#') !== 0) { + oobElement = fragment.querySelector('#' + CSS.escape(selector)) // check if selector is an id first + } + if (!oobElement) { + oobElement = fragment.querySelector(selector) // then support any full selector + } + if (oobElement) { + oobSwap(oobValue, oobElement, settleInfo, rootNode) + } } } + // oob swaps + findAndSwapOobElements(fragment, settleInfo, rootNode) + forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) { + if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) { + // Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap + template.remove() + } + }) } - // oob swaps - findAndSwapOobElements(fragment, settleInfo, rootNode) - forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) { - if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) { - // Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap - template.remove() - } - }) // normal swap if (swapOptions.select) { @@ -1940,6 +1965,9 @@ var htmx = (function() { }) fragment = newFragment } + if (swapSpec.strip) { + fragment = fragment.firstElementChild + } handlePreservedElements(fragment) swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo) restorePreservedElements() @@ -1970,46 +1998,52 @@ var htmx = (function() { if (elt.classList) { elt.classList.add(htmx.config.settlingClass) } - triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo) + if (!isOOBSwap) { + triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo) + } }) - maybeCall(swapOptions.afterSwapCallback) + if (swapOptions.afterSwapCallback) { + swapOptions.afterSwapCallback(settleInfo) + } // merge in new title after swap but before settle if (!swapSpec.ignoreTitle) { handleTitle(settleInfo.title) } - // settle - const doSettle = function() { - forEach(settleInfo.tasks, function(task) { - task.call() - }) - forEach(settleInfo.elts, function(elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass) - } - triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo) - }) + // settle unless this is a oobSwap that settles at the end of its normal swap + if (!oobSettleInfo) { + const doSettle = function() { + forEach(settleInfo.tasks, function(task) { + task.call() + }) + forEach(settleInfo.elts, function(elt) { + if (elt.classList) { + elt.classList.remove(htmx.config.settlingClass) + } + triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo) + }) - if (swapOptions.anchor) { - const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor)) - if (anchorTarget) { - anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' }) + if (swapOptions.anchor) { + const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor)) + if (anchorTarget) { + anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' }) + } } - } - updateScrollState(settleInfo.elts, swapSpec) - maybeCall(swapOptions.afterSettleCallback) - maybeCall(settleResolve) - } + updateScrollState(settleInfo.elts, swapSpec) + maybeCall(swapOptions.afterSettleCallback) + maybeCall(settleResolve) + } - if (swapSpec.settleDelay > 0) { - getWindow().setTimeout(doSettle, swapSpec.settleDelay) - } else { - doSettle() + if (swapSpec.settleDelay > 0) { + getWindow().setTimeout(doSettle, swapSpec.settleDelay) + } else { + doSettle() + } } } - let shouldTransition = htmx.config.globalViewTransitions + let shouldTransition = !isOOBSwap && htmx.config.globalViewTransitions if (swapSpec.hasOwnProperty('transition')) { shouldTransition = swapSpec.transition } @@ -3733,17 +3767,18 @@ var htmx = (function() { /** * @param {Element} elt * @param {HtmxSwapStyle} [swapInfoOverride] + * @param {Object} [defaults] * @returns {HtmxSwapSpecification} */ - function getSwapSpecification(elt, swapInfoOverride) { + function getSwapSpecification(elt, swapInfoOverride, defaults) { const swapInfo = swapInfoOverride || getClosestAttributeValue(elt, 'hx-swap') /** @type HtmxSwapSpecification */ - const swapSpec = { + const swapSpec = defaults || { swapStyle: getInternalData(elt).boosted ? 'innerHTML' : htmx.config.defaultSwapStyle, swapDelay: htmx.config.defaultSwapDelay, settleDelay: htmx.config.defaultSettleDelay } - if (htmx.config.scrollIntoViewOnBoost && getInternalData(elt).boosted && !isAnchorLink(elt)) { + if (!defaults && htmx.config.scrollIntoViewOnBoost && getInternalData(elt).boosted && !isAnchorLink(elt)) { swapSpec.show = 'top' } if (swapInfo) { @@ -3777,8 +3812,14 @@ var htmx = (function() { } else if (value.indexOf('focus-scroll:') === 0) { const focusScrollVal = value.slice('focus-scroll:'.length) swapSpec.focusScroll = focusScrollVal == 'true' + } else if (value.indexOf('strip:') === 0) { + swapSpec.strip = value.slice(6) === 'true' + } else if (value.indexOf('target:') === 0) { + swapSpec.target = value.slice(7) } else if (i == 0) { swapSpec.swapStyle = value + } else if (swapSpec.target) { + swapSpec.target += (' ' + value) // unfound modifers must be part of target selector } else { logError('Unknown modifier in hx-swap: ' + value) } @@ -5138,7 +5179,7 @@ var htmx = (function() { * @property {*} [eventInfo] * @property {string} [anchor] * @property {Element} [contextElement] - * @property {swapCallback} [afterSwapCallback] + * @property {afterSwapCallback} [afterSwapCallback] * @property {swapCallback} [afterSettleCallback] * @property {swapCallback} [beforeSwapCallback] * @property {string} [title] @@ -5149,6 +5190,11 @@ var htmx = (function() { * @callback swapCallback */ +/** + * @callback afterSwapCallback + * @param {HtmxSettleInfo} settleInfo + */ + /** * @typedef {'innerHTML' | 'outerHTML' | 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend' | 'delete' | 'none' | string} HtmxSwapStyle */ @@ -5156,8 +5202,8 @@ var htmx = (function() { /** * @typedef HtmxSwapSpecification * @property {HtmxSwapStyle} swapStyle - * @property {number} swapDelay - * @property {number} settleDelay + * @property {number} [swapDelay] + * @property {number} [settleDelay] * @property {boolean} [transition] * @property {boolean} [ignoreTitle] * @property {string} [head] @@ -5166,6 +5212,8 @@ var htmx = (function() { * @property {string} [show] * @property {string} [showTarget] * @property {boolean} [focusScroll] + * @property {boolean} [strip] + * @property {string} [target] */ /** diff --git a/test/attributes/hx-select-oob.js b/test/attributes/hx-select-oob.js index 9751b081a..8f2af5683 100644 --- a/test/attributes/hx-select-oob.js +++ b/test/attributes/hx-select-oob.js @@ -45,4 +45,47 @@ describe('hx-select-oob attribute', function() { var div2 = byId('d2') div2.innerHTML.should.equal('') }) + + it('hx-select-oob works with advanced swap style like strip:false and target:', function() { + this.server.respondWith('GET', '/test', "
foo
bar
") + var div = make('
') + div.click() + this.server.respond() + div.innerHTML.should.equal('
foo
') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + }) + + it('hx-select-oob works with basic targeting selector', function() { + this.server.respondWith('GET', '/test', "
foo
bar
") + var div = make('
') + make('
') + div.click() + this.server.respond() + div.innerHTML.should.equal('
foo
') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + }) + + it('basic hx-select-oob works with just an id without #', function() { + this.server.respondWith('GET', '/test', "
foo
bar
") + var div = make('
') + make('
') + div.click() + this.server.respond() + div.innerHTML.should.equal('
foo
') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + }) + + it('basic hx-select-oob works with just a non id selector', function() { + this.server.respondWith('GET', '/test', "
foo
bar
") + var div = make('
') + make('
') + div.click() + this.server.respond() + div.innerHTML.should.equal('
foo
') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + }) }) diff --git a/test/attributes/hx-select.js b/test/attributes/hx-select.js index c9a2df5a1..40daee71a 100644 --- a/test/attributes/hx-select.js +++ b/test/attributes/hx-select.js @@ -34,4 +34,13 @@ describe('BOOTSTRAP - htmx AJAX Tests', function() { this.server.respond() div.innerHTML.should.equal('
foo
') }) + + it('properly handles a select with strip:true', function() { + var i = 1 + this.server.respondWith('GET', '/test', "
foo
bar
") + var div = make('
') + div.click() + this.server.respond() + div.innerHTML.should.equal('foo') + }) }) diff --git a/test/attributes/hx-swap-oob.js b/test/attributes/hx-swap-oob.js index 875c4d452..515d6f2ec 100644 --- a/test/attributes/hx-swap-oob.js +++ b/test/attributes/hx-swap-oob.js @@ -387,4 +387,107 @@ describe('hx-swap-oob attribute', function() { element.innerHTML.should.equal('Swapped11') }) }) + + it('swaps into all targets that match the selector with target: format', function() { + this.server.respondWith('GET', '/test', "
Clicked
Swapped12
") + var div = make('
click me
') + make('
No swap
') + make('
Not swapped
') + make('
Not swapped
') + div.click() + this.server.respond() + byId('d1').innerHTML.should.equal('No swap') + byId('d2').innerHTML.should.equal('Swapped12') + byId('d3').innerHTML.should.equal('Swapped12') + }) + + it('swaps innerHTML including wrapping tag when strip:false', function() { + this.server.respondWith('GET', '/test', "
Clicked
Swapped13
") + var div = make('
click me
') + make('
Not swapped
') + div.click() + this.server.respond() + byId('d1').innerHTML.should.equal('
Swapped13
') + }) + + it('swaps outerHTML excluding wrapping tag when strip:true', function() { + this.server.respondWith('GET', '/test', "
Clicked
Swapped14
Swapped14
") + var div = make('
click me
') + make('
Not swapped
') + div.click() + this.server.respond() + byId('d2').innerHTML.should.equal('Swapped14') + byId('d3').innerHTML.should.equal('Swapped14') + }) + + it('handles using template as the encapsulating tag of an inner swap', function() { + this.server.respondWith('GET', '/test', '') + var div = make('
click me
') + make('
') + div.click() + this.server.respond() + byId('foo').innerHTML.should.equal('Swapped15') + }) + + it('handles taget: that includes spaces', function() { + this.server.respondWith('GET', '/test', 'Swapped15') + var div = make('
click me
') + make('
') + div.click() + this.server.respond() + byId('table').innerHTML.should.equal('Swapped15') + }) + + it('works with a swap delay', function(done) { + this.server.respondWith('GET', '/test', 'Clicked!
delay swapped
') + var div = make("
") + var div2 = make("
") + div.click() + this.server.respond() + div.innerText.should.equal('Clicked!') + div2.innerText.should.equal('') + setTimeout(function() { + div2.innerText.should.equal('delay swapped') + done() + }, 30) + }) + + if (/chrome/i.test(navigator.userAgent)) { + it('works with transition:true', function(done) { + this.server.respondWith('GET', '/test', 'Clicked!
transition swapped
') + var div = make("
") + var div2 = make("
") + div.click() + this.server.respond() + div.innerText.should.equal('Clicked!') + div2.innerText.should.equal('') + setTimeout(function() { + div2.innerText.should.equal('transition swapped') + done() + }, 50) + }) + } + + it('works with a settle delay', function(done) { + this.server.respondWith('GET', '/test', 'Clicked!
swapped
') + var div = make("
") + var div2 = make("
") + div.click() + this.server.respond() + div.innerText.should.equal('Clicked!') + div2.classList.contains('foo').should.equal(false) + setTimeout(function() { + byId('foo').classList.contains('foo').should.equal(true) + done() + }, 30) + }) + + it('handles textContent swap style', function() { + this.server.respondWith('GET', '/test', '

Swapped16

!

') + var div = make('
click me
') + var div2 = make('
') + div.click() + this.server.respond() + div2.innerHTML.should.equal('Swapped16!') + }) }) diff --git a/test/attributes/hx-swap.js b/test/attributes/hx-swap.js index 9f29791dc..58bd63146 100644 --- a/test/attributes/hx-swap.js +++ b/test/attributes/hx-swap.js @@ -285,6 +285,10 @@ describe('hx-swap attribute', function() { swapSpec(make("
")).transition.should.equal(true) + swapSpec(make("
")).strip.should.equal(true) + + swapSpec(make("
")).target.should.equal('#table tbody') + swapSpec(make("
")).swapStyle.should.equal('customstyle') }) @@ -560,4 +564,15 @@ describe('hx-swap attribute', function() { htmx._('makeSettleInfo = htmx.backupMakeSettleInfo') } }) + + it('swap innerHTML with strip:true properly', function() { + this.server.respondWith('GET', '/test', 'Clicked!') + + var div = make('
') + div.click() + should.equal(byId('d1'), div) + this.server.respond() + div.innerHTML.should.equal('Clicked!') + div.outerHTML.should.equal('
Clicked!
') + }) }) diff --git a/www/content/attributes/hx-select-oob.md b/www/content/attributes/hx-select-oob.md index ac35ba751..b09c7e6d9 100644 --- a/www/content/attributes/hx-select-oob.md +++ b/www/content/attributes/hx-select-oob.md @@ -27,8 +27,7 @@ This button will issue a `GET` to `/info` and then select the element with the i which will replace the entire button in the DOM, and, in addition, pick out an element with the id `alert` in the response and swap it in for div in the DOM with the same ID. -Each value in the comma separated list of values can specify any valid [`hx-swap`](@/attributes/hx-swap.md) -strategy by separating the selector and the swap strategy with a `:`, with the strategy otherwise defaulting to `outerHTML`. +Each value in the comma-separated list consists of a CSS selector used to locate an element in the response. Optionally, this can be followed by a colon `:` and any valid [`hx-swap-oob`](@/attributes/hx-swap-oob.md) value; if omitted, the swap strategy defaults to outerHTML. As with `hx-swap-oob`, the target element for the swap will default to the element’s ID, but this can be overridden if needed. For example, to prepend the alert content instead of replacing it: diff --git a/www/content/attributes/hx-swap-oob.md b/www/content/attributes/hx-swap-oob.md index 94393fb32..ddf569ef4 100644 --- a/www/content/attributes/hx-swap-oob.md +++ b/www/content/attributes/hx-swap-oob.md @@ -7,7 +7,7 @@ description = """\ +++ The `hx-swap-oob` attribute allows you to specify that some content in a response should be -swapped into the DOM somewhere other than the target, that is "Out of Band". This allows you to piggyback updates to other element updates on a response. +swapped into the DOM somewhere other than the target, that is "Out of Band". This allows you to update multiple elements on a page with a single request. Consider the following response HTML: @@ -23,58 +23,116 @@ Consider the following response HTML: The first div will be swapped into the target the usual manner. The second div, however, will be swapped in as a replacement for the element with the id `alerts`, and will not end up in the target. -The value of the `hx-swap-oob` can be: +### Syntax Options -* `true` -* any valid [`hx-swap`](@/attributes/hx-swap.md) value -* any valid [`hx-swap`](@/attributes/hx-swap.md) value, followed by a colon, followed by a CSS selector +The value of the `hx-swap-oob` attribute can be: + +* `true` - Uses the default `outerHTML` swap strategy with ID-based targeting +* Any valid basic [`hx-swap`](@/attributes/hx-swap.md) strategy (innerHTML, outerHTML, beforebegin, etc) +* Any valid basic [`hx-swap`](@/attributes/hx-swap.md) strategy, followed by a colon, followed by a target CSS selector +* Any valid complex [`hx-swap`](@/attributes/hx-swap.md) value including modifiers like `target:` to set the target of the swap + +### Behavior Details If the value is `true` or `outerHTML` (which are equivalent) the element will be swapped inline. -If a swap value is given, that swap strategy will be used and the encapsulating tag pair will be stripped for all strategies other than `outerHTML`. +If a different swap strategy than `true`/`outerHTML` is supplied the encapsulating tag pair will be stripped so only the inner contents of the element will be used. You can now use `strip:true` modifier to enable tag stripping for `outerHTML` or `strip:false` to disable it for the other strategies when required. + +If a selector is given, all elements matched by that selector will be swapped. If not, the element on the page with an ID matching that of the oob element will be swapped instead. + +### Modifers -If a selector is given, all elements matched by that selector will be swapped. If not, the element with an ID matching the new content will be swapped. +The following modifers from [`hx-swap`](@/attributes/hx-swap.md) can now be included after the swap strategy sperated by spaces: + +* `transition:` - Wrap the oob swap in its own [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) +* `swap:`/`settle:` - Delay the swap or settle time for the oob swap and make its settle independant from the main swap +* `scroll:`/`show:`/`focus-scroll:` - Control the scrolling behaviour of the oob swap +* `strip:` - Override the stripping or not of the oob elements encapsulating tag pair +* `target:` - Set a custom CSS selector to use as the target + +Before modifers were supported the swap strategy value could not contain spaces and the target selector was placed after a colon like `innerHTML:#status`. Now when the swap value contains a space it is parsed as swap modifers and you have to be explicit and use the `target:` modifier like `innerHTML swap:1s target:#status`. ### Using alternate swap strategies -As mentioned previously when using swap strategies other than `true` or `outerHTML` the encapsulating tags are stripped, as such you need to excapsulate the returned data with the correct tags for the context. +Here are some examples of the various swap strategies: + +```html + +
New notification!
+ + +
Processing complete
+ + +
Processing complete
+ + +
New log entry
+ + +
Updated content
+``` + +### Proper Element Encapsulation + +As mentioned previously when using swap strategies other than `true` or `outerHTML` the encapsulating tags are stripped by default, however you still need to excapsulate the returned data with the correct tags for the content so it can be parsed as valid html. When trying to insert a `` in a table that uses ``: ```html - - ... - + ... ``` A "plain" table: ```html - - - ... - +
+ ...
``` A `
  • ` may be encapsulated in `
      `, `
        `, `
        ` or ``, for example: ```html -
          +
          • ...
          ``` A `

          ` can be encapsulated in `

          ` or ``: ```html - +

          ...

          ``` +You can also now use `template` tag as a universal tag that can encapsulate all tag types: +```html + +``` + +### Overriding Element Encapsulation + +Another new option is the `strip:true` swap modifier that allows you to replace an element with multiple nodes: +```html +
          +
          Replace original
          +
          And add something more
          +
          +``` + +You can also use `strip:false` to allow you to place the oob element itself in various locations +```html + +
          + User Name is already taken! +
          +``` + ### Troublesome Tables and lists -Note that you can use a `template` tag to encapsulate types of elements that, by the HTML spec, can't stand on their own in the -DOM (``, ``, ``, ``, ``, ``, ``, ``, `` & `
        • `). +Note that you can use a `template` tag to encapsulate types of elements that, by the HTML spec, can not be placed adjacent to other normal tags in the DOM (``, ``, ``, ``, ``, ``, ``, ``, `` & `
        • `). Here is an example with an out-of-band swap of a table row being encapsulated in this way: @@ -90,6 +148,7 @@ Here is an example with an out-of-band swap of a table row being encapsulated in ``` Note that these template tags will be removed from the final content of the page. +When the main content node tag is one of the restricted ones you may also need to wrap the oob nodes. ### Slippery SVGs diff --git a/www/content/attributes/hx-swap.md b/www/content/attributes/hx-swap.md index 250b2881b..1e0c6c677 100644 --- a/www/content/attributes/hx-swap.md +++ b/www/content/attributes/hx-swap.md @@ -141,6 +141,22 @@ Alternatively, if you want the page to automatically scroll to the focused eleme hx-swap="outerHTML focus-scroll:false"/> ``` +#### hx-swap-oob: `strip` & `target` + +Designed for use with hx-swap-oob there are two modifers that have been added. `strip:true` allows you to override outerHTML swaps so that it will swap in the inner contents and not the outer tag. It can also be used with normal hx-swaps as well but here it will swap in the inner contents of the first Element in the response content but any later Elements will be lost. + +```html + +
          + Get Some Content +
          +``` + +`strip:false` is used for hx-swap-oob inner style swap strategies to allow them to swap in the encapsulating tag. + +`target:` is only usable with hx-swap-oob and not normal hx-swaps and it allows you to set a custom target selector to replace during the oob swap. + ## Notes * `hx-swap` is inherited and can be placed on a parent element diff --git a/www/content/events.md b/www/content/events.md index 47aa460fa..a1ddf3c76 100644 --- a/www/content/events.md +++ b/www/content/events.md @@ -375,6 +375,7 @@ This event is triggered as part of an [out of band swap](@/docs.md#oob_swaps) an * `detail.shouldSwap` - if the content will be swapped (defaults to `true`) * `detail.target` - the target of the swap * `detail.fragment` - the response fragment +* `detail.swapSpec` - the swapSpec to be used containing the swapStyle ### Event - `htmx:oobErrorNoTarget` {#htmx:oobErrorNoTarget} From a31b646012e59979063ed47d2aba7464c7437733 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Wed, 9 Jul 2025 13:32:20 +1200 Subject: [PATCH 02/14] remove css.Escape from selectOOB that is not needed and add tests for : in ext swap style --- src/htmx.js | 9 +++++---- test/attributes/hx-ext.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 702e52954..fb16fdc83 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1457,14 +1457,15 @@ var htmx = (function() { let selector = '#' + CSS.escape(getRawAttribute(oobElement, 'id')) /** @type HtmxSwapStyle */ let swapStyle = 'outerHTML' + const split = oobValue.lastIndexOf(':') if (oobValue === 'true') { // do nothing - } else if (oobValue.indexOf(':') > 0) { - swapStyle = oobValue.substring(0, oobValue.lastIndexOf(':')) + } else if (split > 0) { + swapStyle = oobValue.substring(0, split) if (WHITESPACE.test(swapStyle)) { swapStyle = oobValue // if whitespace then treat whole oobValue as a full swap spec with retarget: or other modifiers } else { - selector = oobValue.substring(oobValue.lastIndexOf(':') + 1) // otherwise treat anything after : as selector for old format + selector = oobValue.substring(split + 1) // otherwise treat anything after : as selector for old format } } else { swapStyle = oobValue @@ -1937,7 +1938,7 @@ var htmx = (function() { const oobValue = oobSelectValue.length > 0 ? oobSelectValue.join(':') : 'true' let oobElement if (selector.indexOf('#') !== 0) { - oobElement = fragment.querySelector('#' + CSS.escape(selector)) // check if selector is an id first + oobElement = fragment.querySelector('#' + selector) // check if selector is an id first } if (!oobElement) { oobElement = fragment.querySelector(selector) // then support any full selector diff --git a/test/attributes/hx-ext.js b/test/attributes/hx-ext.js index adc047fe5..cf5b1b76c 100644 --- a/test/attributes/hx-ext.js +++ b/test/attributes/hx-ext.js @@ -56,6 +56,17 @@ describe('hx-ext attribute', function() { } } }) + htmx.defineExtension('ext-6', { + isInlineSwap: function(swapStyle) { + return swapStyle === 'morph:outerHTML' + }, + handleSwap: function(swapStyle, target, fragment, settleInfo) { + if (swapStyle === 'morph:outerHTML') { + const swapOuterHTML = htmx._('swapOuterHTML') + swapOuterHTML(target, fragment, settleInfo) + } + } + }) }) afterEach(function() { @@ -66,6 +77,7 @@ describe('hx-ext attribute', function() { htmx.removeExtension('ext-3') htmx.removeExtension('ext-4') htmx.removeExtension('ext-5') + htmx.removeExtension('ext-6') }) it('A simple extension is invoked properly', function() { @@ -195,4 +207,28 @@ describe('hx-ext attribute', function() { this.server.respond() byId('b1').innerHTML.should.equal('Bar') }) + + it('oob swap via swap extension can accept custom swap styles with ":" if it has a custom selector', function() { + this.server.respondWith( + 'GET', + '/test', + '
          Bar
          ' + ) + var btn = make('
          Foo
          ') + btn.click() + this.server.respond() + byId('b1').innerHTML.should.equal('Bar') + }) + + it('oob swap via swap extension can accept custom swap styles with ":" if it is a complex style', function() { + this.server.respondWith( + 'GET', + '/test', + '
          Bar
          ' + ) + var btn = make('
          Foo
          ') + btn.click() + this.server.respond() + byId('b1').innerHTML.should.equal('Bar') + }) }) From 6185a8a9e57fa021e9e7fd29e72446c66ef00c41 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Thu, 10 Jul 2025 16:37:37 +1200 Subject: [PATCH 03/14] improve detection of swap modifiers and fallback to old behaviour if it can't parse the new swap specification --- src/htmx.js | 30 +++++++++++++-------------- test/attributes/hx-ext.js | 12 ----------- www/content/attributes/hx-swap-oob.md | 2 +- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index fb16fdc83..730eba688 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1455,23 +1455,19 @@ var htmx = (function() { function oobSwap(oobValue, oobElement, settleInfo, rootNode) { rootNode = rootNode || getDocument() let selector = '#' + CSS.escape(getRawAttribute(oobElement, 'id')) - /** @type HtmxSwapStyle */ - let swapStyle = 'outerHTML' - const split = oobValue.lastIndexOf(':') - if (oobValue === 'true') { - // do nothing - } else if (split > 0) { - swapStyle = oobValue.substring(0, split) - if (WHITESPACE.test(swapStyle)) { - swapStyle = oobValue // if whitespace then treat whole oobValue as a full swap spec with retarget: or other modifiers + var swapSpec = getSwapSpecification(oobElement, oobValue, {}) + if (swapSpec && Object.keys(swapSpec).length > 1) { + selector = swapSpec.target || selector // if it parses as a full swapSpec and is not a single value then use it + } else { + const split = oobValue.indexOf(':') // otherwise split as style:selector for legacy support + if (split !== -1) { + swapSpec = { swapStyle: oobValue.substring(0, split) } + selector = oobValue.substring(split + 1) } else { - selector = oobValue.substring(split + 1) // otherwise treat anything after : as selector for old format + swapSpec = { swapStyle: oobValue === 'true' ? 'outerHTML' : oobValue } } - } else { - swapStyle = oobValue } - var swapSpec = getSwapSpecification(oobElement, swapStyle, { target: selector }) - selector = swapSpec.target + oobElement.removeAttribute('hx-swap-oob') oobElement.removeAttribute('data-hx-swap-oob') const targets = querySelectorAllExt(rootNode, selector, false) @@ -3822,7 +3818,11 @@ var htmx = (function() { } else if (swapSpec.target) { swapSpec.target += (' ' + value) // unfound modifers must be part of target selector } else { - logError('Unknown modifier in hx-swap: ' + value) + if (!defaults) { + logError('Unknown modifier in hx-swap: ' + value) + } else { + return + } } } } diff --git a/test/attributes/hx-ext.js b/test/attributes/hx-ext.js index cf5b1b76c..e0214bd89 100644 --- a/test/attributes/hx-ext.js +++ b/test/attributes/hx-ext.js @@ -208,18 +208,6 @@ describe('hx-ext attribute', function() { byId('b1').innerHTML.should.equal('Bar') }) - it('oob swap via swap extension can accept custom swap styles with ":" if it has a custom selector', function() { - this.server.respondWith( - 'GET', - '/test', - '
          Bar
          ' - ) - var btn = make('
          Foo
          ') - btn.click() - this.server.respond() - byId('b1').innerHTML.should.equal('Bar') - }) - it('oob swap via swap extension can accept custom swap styles with ":" if it is a complex style', function() { this.server.respondWith( 'GET', diff --git a/www/content/attributes/hx-swap-oob.md b/www/content/attributes/hx-swap-oob.md index ddf569ef4..0f943355e 100644 --- a/www/content/attributes/hx-swap-oob.md +++ b/www/content/attributes/hx-swap-oob.md @@ -50,7 +50,7 @@ The following modifers from [`hx-swap`](@/attributes/hx-swap.md) can now be incl * `strip:` - Override the stripping or not of the oob elements encapsulating tag pair * `target:` - Set a custom CSS selector to use as the target -Before modifers were supported the swap strategy value could not contain spaces and the target selector was placed after a colon like `innerHTML:#status`. Now when the swap value contains a space it is parsed as swap modifers and you have to be explicit and use the `target:` modifier like `innerHTML swap:1s target:#status`. +Before modifers were supported the swap strategy value could not contain spaces and the target selector was placed after a colon like `innerHTML:#status`. Now when the `hx-swap-oob` value is parsed and checked to see if is a valid swap specification with modifers before using it. If this parsing fails it falls back to the old legacy way and treats the first `:` as a seperator between a swap style and CSS selector. Any invalid or incorrectly spelt modifers used will prevent it using a valid selector preventing the oob swap. ### Using alternate swap strategies From c04c8ee70070ea2af65242526dfc259bd9a3901e Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Fri, 11 Jul 2025 00:27:15 +1200 Subject: [PATCH 04/14] Add error logging for bad oob swap modifers --- src/htmx.js | 3 +++ test/attributes/hx-swap-oob.js | 28 ++++++++++++++++++++++++++- www/content/attributes/hx-swap-oob.md | 2 +- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 730eba688..f4a325a43 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1467,6 +1467,9 @@ var htmx = (function() { swapSpec = { swapStyle: oobValue === 'true' ? 'outerHTML' : oobValue } } } + if (WHITESPACE.test(swapSpec.swapStyle)) { + logError('invalid modifier in hx-swap-oob: ' + oobValue) + } oobElement.removeAttribute('hx-swap-oob') oobElement.removeAttribute('data-hx-swap-oob') diff --git a/test/attributes/hx-swap-oob.js b/test/attributes/hx-swap-oob.js index 515d6f2ec..e8c330de9 100644 --- a/test/attributes/hx-swap-oob.js +++ b/test/attributes/hx-swap-oob.js @@ -485,9 +485,35 @@ describe('hx-swap-oob attribute', function() { it('handles textContent swap style', function() { this.server.respondWith('GET', '/test', '

          Swapped16

          !

          ') var div = make('
          click me
          ') - var div2 = make('
          ') + var div2 = make('
          ') div.click() this.server.respond() div2.innerHTML.should.equal('Swapped16!') }) + + it('invalid swap modifers with ":" will prevent oob swap and log error', function() { + this.server.respondWith('GET', '/test', '
          Swapped17
          ') + var div = make('
          click me
          ') + var div2 = make('
          ') + var errorSpy = sinon.spy(console, 'error') + try { + div.click() + this.server.respond() + } catch (e) {} + errorSpy.called.should.equal(true) + div2.innerHTML.should.equal('') + errorSpy.restore() + }) + + it('invalid swap modifers without ":" will fall back to innerHTML swap and log error', function() { + this.server.respondWith('GET', '/test', '
          Swapped18
          ') + var div = make('
          click me
          ') + var div2 = make('
          ') + var errorSpy = sinon.spy(console, 'error') + div.click() + this.server.respond() + errorSpy.called.should.equal(true) + div2.innerHTML.should.equal('Swapped18') + errorSpy.restore() + }) }) diff --git a/www/content/attributes/hx-swap-oob.md b/www/content/attributes/hx-swap-oob.md index 0f943355e..8741f67a4 100644 --- a/www/content/attributes/hx-swap-oob.md +++ b/www/content/attributes/hx-swap-oob.md @@ -50,7 +50,7 @@ The following modifers from [`hx-swap`](@/attributes/hx-swap.md) can now be incl * `strip:` - Override the stripping or not of the oob elements encapsulating tag pair * `target:` - Set a custom CSS selector to use as the target -Before modifers were supported the swap strategy value could not contain spaces and the target selector was placed after a colon like `innerHTML:#status`. Now when the `hx-swap-oob` value is parsed and checked to see if is a valid swap specification with modifers before using it. If this parsing fails it falls back to the old legacy way and treats the first `:` as a seperator between a swap style and CSS selector. Any invalid or incorrectly spelt modifers used will prevent it using a valid selector preventing the oob swap. +Before modifers were supported the swap strategy value could not contain spaces and the target selector was placed after a colon like `innerHTML:#status`. Now the `hx-swap-oob` value is parsed and checked to see if is a valid swap specification with modifers before using it. If this parsing fails it falls back to the old legacy method and treats the first `:` as a seperator between a swap style and CSS selector. Any invalid or incorrectly spelt modifers will log errors and will either fail or fall back to an innerHTML oob swap. ### Using alternate swap strategies From 73fbd8ef2509f1529edb6224a1bf717c2c4f6872 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Fri, 11 Jul 2025 10:41:33 +1200 Subject: [PATCH 05/14] make getSwapSpec invalid modifers early return clearer --- src/htmx.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index f4a325a43..b27319036 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -3821,11 +3821,10 @@ var htmx = (function() { } else if (swapSpec.target) { swapSpec.target += (' ' + value) // unfound modifers must be part of target selector } else { - if (!defaults) { - logError('Unknown modifier in hx-swap: ' + value) - } else { - return + if (defaults) { + return // on invalid modifers allow oob swap to fall back to old style } + logError('Unknown modifier in hx-swap: ' + value) } } } From 0391f4938976e6303bb8bcf10c13bfb9136778f5 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sun, 20 Jul 2025 12:48:21 +1200 Subject: [PATCH 06/14] Improve hx-selecto-oob edge cases --- src/htmx.js | 4 ++-- test/attributes/hx-select-oob.js | 24 ++++++++++++++++++++++++ www/content/attributes/hx-select-oob.md | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index b27319036..d468d8de4 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1934,9 +1934,9 @@ var htmx = (function() { for (let i = 0; i < oobSelectValues.length; i++) { const oobSelectValue = oobSelectValues[i].split(':') const selector = oobSelectValue.shift().trim() - const oobValue = oobSelectValue.length > 0 ? oobSelectValue.join(':') : 'true' + const oobValue = oobSelectValue.join(':') || 'true' let oobElement - if (selector.indexOf('#') !== 0) { + if (selector.indexOf('#') !== 0 && /^[A-Za-z\-_][A-Za-z0-9\-_:.]*$/.test(selector)) { oobElement = fragment.querySelector('#' + selector) // check if selector is an id first } if (!oobElement) { diff --git a/test/attributes/hx-select-oob.js b/test/attributes/hx-select-oob.js index 8f2af5683..8292b290e 100644 --- a/test/attributes/hx-select-oob.js +++ b/test/attributes/hx-select-oob.js @@ -88,4 +88,28 @@ describe('hx-select-oob attribute', function() { var div2 = byId('d2') div2.innerHTML.should.equal('bar') }) + + it('hx-select-oob can end with a blank swap style which is ignored', function() { + this.server.respondWith('GET', '/test', "
          foo
          bar
          ") + var div = make('
          ') + make('
          ') + div.click() + this.server.respond() + div.innerHTML.should.equal('
          foo
          ') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + div2.classList.contains('foo').should.equal(true) + }) + + it('basic hx-select-oob works supports non text based selectors', function() { + this.server.respondWith('GET', '/test', "
          foo
          bar
          ") + var div = make('
          ') + make('
          ') + div.click() + this.server.respond() + div.innerHTML.should.equal('
          foo
          ') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + div2.classList.contains('foo').should.equal(true) + }) }) diff --git a/www/content/attributes/hx-select-oob.md b/www/content/attributes/hx-select-oob.md index b09c7e6d9..62834f1d2 100644 --- a/www/content/attributes/hx-select-oob.md +++ b/www/content/attributes/hx-select-oob.md @@ -46,3 +46,4 @@ For example, to prepend the alert content instead of replacing it: ## Notes * `hx-select-oob` is inherited and can be placed on a parent element +* Each CSS selector value used will only find the first matching element in the response From cf2f81a68e232e2819b3de10d84da8319c686def Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sun, 20 Jul 2025 16:27:05 +1200 Subject: [PATCH 07/14] improve hx-select-oob regex --- src/htmx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index d468d8de4..2bd23052c 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1936,7 +1936,7 @@ var htmx = (function() { const selector = oobSelectValue.shift().trim() const oobValue = oobSelectValue.join(':') || 'true' let oobElement - if (selector.indexOf('#') !== 0 && /^[A-Za-z\-_][A-Za-z0-9\-_:.]*$/.test(selector)) { + if (selector.indexOf('#') !== 0 && /^[A-Za-z\-_][A-Za-z0-9\-_]*$/.test(selector)) { oobElement = fragment.querySelector('#' + selector) // check if selector is an id first } if (!oobElement) { From 0974c548da5c46ef245f1e68fe7dbe21b722526a Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sun, 20 Jul 2025 16:50:42 +1200 Subject: [PATCH 08/14] shrink regex a little more --- src/htmx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index 2bd23052c..bc4da8b2b 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1936,7 +1936,7 @@ var htmx = (function() { const selector = oobSelectValue.shift().trim() const oobValue = oobSelectValue.join(':') || 'true' let oobElement - if (selector.indexOf('#') !== 0 && /^[A-Za-z\-_][A-Za-z0-9\-_]*$/.test(selector)) { + if (selector.indexOf('#') !== 0 && /^[a-z\-_][a-z0-9\-_]*$/i.test(selector)) { oobElement = fragment.querySelector('#' + selector) // check if selector is an id first } if (!oobElement) { From c5786de38c61e9eecd7e7ea75c9859e0e579bb4c Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Mon, 21 Jul 2025 01:49:22 +1200 Subject: [PATCH 09/14] add multi selector support to hx-select-oob --- src/htmx.js | 14 +++++++------- test/attributes/hx-select-oob.js | 16 ++++++++++++++++ www/content/attributes/hx-select-oob.md | 3 +-- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index bc4da8b2b..7b4f354ec 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1935,16 +1935,16 @@ var htmx = (function() { const oobSelectValue = oobSelectValues[i].split(':') const selector = oobSelectValue.shift().trim() const oobValue = oobSelectValue.join(':') || 'true' - let oobElement + let oobElements if (selector.indexOf('#') !== 0 && /^[a-z\-_][a-z0-9\-_]*$/i.test(selector)) { - oobElement = fragment.querySelector('#' + selector) // check if selector is an id first + oobElements = fragment.querySelectorAll('#' + selector) // check if selector is an id first } - if (!oobElement) { - oobElement = fragment.querySelector(selector) // then support any full selector - } - if (oobElement) { - oobSwap(oobValue, oobElement, settleInfo, rootNode) + if (!oobElements || !oobElements.length) { + oobElements = fragment.querySelectorAll(selector) // then support any full selector } + forEach(oobElements, function(elt) { + oobSwap(oobValue, elt, settleInfo, rootNode) + }) } } // oob swaps diff --git a/test/attributes/hx-select-oob.js b/test/attributes/hx-select-oob.js index 8292b290e..5fbfee038 100644 --- a/test/attributes/hx-select-oob.js +++ b/test/attributes/hx-select-oob.js @@ -112,4 +112,20 @@ describe('hx-select-oob attribute', function() { div2.innerHTML.should.equal('bar') div2.classList.contains('foo').should.equal(true) }) + + it('hx-select-oob can select multiple elements with a selector', function() { + this.server.respondWith('GET', '/test', "
          foo
          bar
          baz
          ") + var div = make('
          ') + make('
          ') + make('
          ') + div.click() + this.server.respond() + div.innerHTML.should.equal('
          foo
          ') + var div2 = byId('d2') + div2.innerHTML.should.equal('bar') + div2.classList.contains('foo').should.equal(true) + var div3 = byId('d3') + div3.innerHTML.should.equal('baz') + div3.classList.contains('foo').should.equal(true) + }) }) diff --git a/www/content/attributes/hx-select-oob.md b/www/content/attributes/hx-select-oob.md index 62834f1d2..18a72d75a 100644 --- a/www/content/attributes/hx-select-oob.md +++ b/www/content/attributes/hx-select-oob.md @@ -27,7 +27,7 @@ This button will issue a `GET` to `/info` and then select the element with the i which will replace the entire button in the DOM, and, in addition, pick out an element with the id `alert` in the response and swap it in for div in the DOM with the same ID. -Each value in the comma-separated list consists of a CSS selector used to locate an element in the response. Optionally, this can be followed by a colon `:` and any valid [`hx-swap-oob`](@/attributes/hx-swap-oob.md) value; if omitted, the swap strategy defaults to outerHTML. As with `hx-swap-oob`, the target element for the swap will default to the element’s ID, but this can be overridden if needed. +Each value in the comma-separated list consists of a CSS selector used to locate the elements in the response. Optionally, this can be followed by a colon `:` and any valid [`hx-swap-oob`](@/attributes/hx-swap-oob.md) value; if omitted, the swap strategy defaults to outerHTML. As with `hx-swap-oob`, the target element for the swap will default to the element’s ID, but this can be overridden if needed. For example, to prepend the alert content instead of replacing it: @@ -46,4 +46,3 @@ For example, to prepend the alert content instead of replacing it: ## Notes * `hx-select-oob` is inherited and can be placed on a parent element -* Each CSS selector value used will only find the first matching element in the response From 9558c799a2f2c6069baeef2581eac0f290b0fff4 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Mon, 21 Jul 2025 02:18:43 +1200 Subject: [PATCH 10/14] remove confusing test --- test/attributes/hx-swap-oob.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/attributes/hx-swap-oob.js b/test/attributes/hx-swap-oob.js index e8c330de9..8a53c73d7 100644 --- a/test/attributes/hx-swap-oob.js +++ b/test/attributes/hx-swap-oob.js @@ -504,16 +504,4 @@ describe('hx-swap-oob attribute', function() { div2.innerHTML.should.equal('') errorSpy.restore() }) - - it('invalid swap modifers without ":" will fall back to innerHTML swap and log error', function() { - this.server.respondWith('GET', '/test', '
          Swapped18
          ') - var div = make('
          click me
          ') - var div2 = make('
          ') - var errorSpy = sinon.spy(console, 'error') - div.click() - this.server.respond() - errorSpy.called.should.equal(true) - div2.innerHTML.should.equal('Swapped18') - errorSpy.restore() - }) }) From a77903bc32a43f5ce496d74b2c177fbdcfef9196 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Mon, 21 Jul 2025 10:52:22 +1200 Subject: [PATCH 11/14] handle hx-select-oob escapped selector with . --- src/htmx.js | 2 +- test/attributes/hx-select-oob.js | 11 +++++++++++ test/attributes/hx-swap.js | 2 ++ www/content/attributes/hx-select-oob.md | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index 7b4f354ec..d0311fb0e 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1936,7 +1936,7 @@ var htmx = (function() { const selector = oobSelectValue.shift().trim() const oobValue = oobSelectValue.join(':') || 'true' let oobElements - if (selector.indexOf('#') !== 0 && /^[a-z\-_][a-z0-9\-_]*$/i.test(selector)) { + if (selector.indexOf('#') !== 0 && /^[a-z\-_](\\\.|[a-z0-9\-_])*$/i.test(selector)) { oobElements = fragment.querySelectorAll('#' + selector) // check if selector is an id first } if (!oobElements || !oobElements.length) { diff --git a/test/attributes/hx-select-oob.js b/test/attributes/hx-select-oob.js index 5fbfee038..b30f66abb 100644 --- a/test/attributes/hx-select-oob.js +++ b/test/attributes/hx-select-oob.js @@ -113,6 +113,17 @@ describe('hx-select-oob attribute', function() { div2.classList.contains('foo').should.equal(true) }) + it('basic hx-select-oob works with CSS escaped id containing "."', function() { + this.server.respondWith('GET', '/test', "
          foo
          bar
          ") + var div = make('
          ') + make('
          ') + div.click() + this.server.respond() + div.innerHTML.should.equal('
          foo
          ') + var div2 = byId('my.div3') + div2.innerHTML.should.equal('bar') + }) + it('hx-select-oob can select multiple elements with a selector', function() { this.server.respondWith('GET', '/test', "
          foo
          bar
          baz
          ") var div = make('
          ') diff --git a/test/attributes/hx-swap.js b/test/attributes/hx-swap.js index 58bd63146..9f7f4e3d1 100644 --- a/test/attributes/hx-swap.js +++ b/test/attributes/hx-swap.js @@ -289,6 +289,8 @@ describe('hx-swap attribute', function() { swapSpec(make("
          ")).target.should.equal('#table tbody') + swapSpec(make("
          ")).target.should.equal('#table tbody') + swapSpec(make("
          ")).swapStyle.should.equal('customstyle') }) diff --git a/www/content/attributes/hx-select-oob.md b/www/content/attributes/hx-select-oob.md index 18a72d75a..be461d2ef 100644 --- a/www/content/attributes/hx-select-oob.md +++ b/www/content/attributes/hx-select-oob.md @@ -46,3 +46,4 @@ For example, to prepend the alert content instead of replacing it: ## Notes * `hx-select-oob` is inherited and can be placed on a parent element +* the CSS selector used to locate the elements in the response can not contain a `:` From 4a2b220f54bb1fcb4c986099f42d342624a92906 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Mon, 21 Jul 2025 14:22:09 +1200 Subject: [PATCH 12/14] oob swaps need to set swappingClass for when they use css transitions now --- src/htmx.js | 7 ++++--- test/attributes/hx-swap-oob.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index d0311fb0e..7550c29fd 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1886,6 +1886,10 @@ var htmx = (function() { if (!swapOptions) { swapOptions = {} } + + target = resolveTarget(target) + target.classList.add(htmx.config.swappingClass) + // optional transition API promise callbacks let settleResolve = null let settleReject = null @@ -1895,7 +1899,6 @@ var htmx = (function() { let doSwap = function() { maybeCall(swapOptions.beforeSwapCallback) - target = resolveTarget(target) const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument() // preserve focus and selection @@ -4933,8 +4936,6 @@ var htmx = (function() { swapSpec.ignoreTitle = ignoreTitle } - target.classList.add(htmx.config.swappingClass) - if (responseInfoSelect) { selectOverride = responseInfoSelect } diff --git a/test/attributes/hx-swap-oob.js b/test/attributes/hx-swap-oob.js index 8a53c73d7..e7678d3ea 100644 --- a/test/attributes/hx-swap-oob.js +++ b/test/attributes/hx-swap-oob.js @@ -435,7 +435,7 @@ describe('hx-swap-oob attribute', function() { make('
          ') div.click() this.server.respond() - byId('table').innerHTML.should.equal('Swapped15') + byId('table').innerHTML.should.equal('Swapped15') }) it('works with a swap delay', function(done) { From 6626575e3a6b11402e54839da6804a44fdfc4171 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Mon, 21 Jul 2025 23:51:13 +1200 Subject: [PATCH 13/14] remove duplicate line --- src/htmx.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 7550c29fd..7b2c2b486 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1508,11 +1508,10 @@ var htmx = (function() { } } ) - oobElement.parentNode.removeChild(oobElement) } else { - oobElement.parentNode.removeChild(oobElement) triggerErrorEvent(getDocument().body, 'htmx:oobErrorNoTarget', { content: oobElement }) } + oobElement.parentNode.removeChild(oobElement) return oobValue } From b8b342b419ce7b4e3d9e15d4c00363aadaaef0c2 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 22 Jul 2025 16:49:04 +1200 Subject: [PATCH 14/14] Fix empty class that miss targeted oob swaps can cause --- src/htmx.js | 10 ++++------ test/attributes/hx-swap-oob.js | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 7b2c2b486..73484e88f 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1886,8 +1886,7 @@ var htmx = (function() { swapOptions = {} } - target = resolveTarget(target) - target.classList.add(htmx.config.swappingClass) + addClassToElement(target, htmx.config.swappingClass) // optional transition API promise callbacks let settleResolve = null @@ -1898,6 +1897,7 @@ var htmx = (function() { let doSwap = function() { maybeCall(swapOptions.beforeSwapCallback) + target = resolveTarget(target) const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument() // preserve focus and selection @@ -1995,7 +1995,7 @@ var htmx = (function() { } } - target.classList.remove(htmx.config.swappingClass) + removeClassFromElement(target, htmx.config.swappingClass) forEach(settleInfo.elts, function(elt) { if (elt.classList) { elt.classList.add(htmx.config.settlingClass) @@ -2020,9 +2020,7 @@ var htmx = (function() { task.call() }) forEach(settleInfo.elts, function(elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass) - } + removeClassFromElement(elt, htmx.config.settlingClass) triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo) }) diff --git a/test/attributes/hx-swap-oob.js b/test/attributes/hx-swap-oob.js index e7678d3ea..8a53c73d7 100644 --- a/test/attributes/hx-swap-oob.js +++ b/test/attributes/hx-swap-oob.js @@ -435,7 +435,7 @@ describe('hx-swap-oob attribute', function() { make('
          ') div.click() this.server.respond() - byId('table').innerHTML.should.equal('Swapped15') + byId('table').innerHTML.should.equal('Swapped15') }) it('works with a swap delay', function(done) {