fix for hx-swab-oob within web components (#2846)

* Failing test for oob-swap within web components

* hx-swap-oob respects shadow roots

* Lint and type fixes

* fix jsdoc types for rootNode parameter

* Fix for linter issue I was confused about before

* oob swaps handle global correctly

* swap uses contextElement if available, document if not

Previous a commit made swapOptions.contextElement a required field. This
could have harmful ramifications for extensions and users, so instead,
the old behavior of assuming document as a root will be used if the
contextElement is not provided.

* rootNode parameter is optional in oobSwap

If not provided, it will fall back to using document as rootNode. jsdocs
have been updated for oobSwap and findAndSwapElements accordingly.
This commit is contained in:
Jonathan 2024-10-02 20:44:41 -05:00 committed by GitHub
parent 8c6582679b
commit 99285cd5c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 98 additions and 7 deletions

View File

@ -1405,9 +1405,11 @@ var htmx = (function() {
* @param {string} oobValue
* @param {Element} oobElement
* @param {HtmxSettleInfo} settleInfo
* @param {Node|Document} [rootNode]
* @returns
*/
function oobSwap(oobValue, oobElement, settleInfo) {
function oobSwap(oobValue, oobElement, settleInfo, rootNode) {
rootNode = rootNode || getDocument()
let selector = '#' + getRawAttribute(oobElement, 'id')
/** @type HtmxSwapStyle */
let swapStyle = 'outerHTML'
@ -1422,7 +1424,7 @@ var htmx = (function() {
oobElement.removeAttribute('hx-swap-oob')
oobElement.removeAttribute('data-hx-swap-oob')
const targets = getDocument().querySelectorAll(selector)
const targets = querySelectorAllExt(rootNode, selector, false)
if (targets) {
forEach(
targets,
@ -1807,14 +1809,15 @@ var htmx = (function() {
/**
* @param {DocumentFragment} fragment
* @param {HtmxSettleInfo} settleInfo
* @param {Node|Document} [rootNode]
*/
function findAndSwapOobElements(fragment, settleInfo) {
function findAndSwapOobElements(fragment, settleInfo, rootNode) {
var oobElts = findAll(fragment, '[hx-swap-oob], [data-hx-swap-oob]')
forEach(oobElts, function(oobElement) {
if (htmx.config.allowNestedOobSwaps || oobElement.parentElement === null) {
const oobValue = getAttributeValue(oobElement, 'hx-swap-oob')
if (oobValue != null) {
oobSwap(oobValue, oobElement, settleInfo)
oobSwap(oobValue, oobElement, settleInfo, rootNode)
}
} else {
oobElement.removeAttribute('hx-swap-oob')
@ -1838,6 +1841,7 @@ var htmx = (function() {
}
target = resolveTarget(target)
const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument()
// preserve focus and selection
const activeElt = document.activeElement
@ -1876,14 +1880,14 @@ var htmx = (function() {
const oobValue = oobSelectValue[1] || 'true'
const oobElement = fragment.querySelector('#' + id)
if (oobElement) {
oobSwap(oobValue, oobElement, settleInfo)
oobSwap(oobValue, oobElement, settleInfo, rootNode)
}
}
}
// oob swaps
findAndSwapOobElements(fragment, settleInfo)
findAndSwapOobElements(fragment, settleInfo, rootNode)
forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) {
if (findAndSwapOobElements(template.content, settleInfo)) {
if (findAndSwapOobElements(template.content, settleInfo, rootNode)) {
// Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap
template.remove()
}

View File

@ -260,4 +260,91 @@ describe('hx-swap-oob attribute', function() {
byId('td1').innerHTML.should.equal('hey')
})
}
for (const config of [{ allowNestedOobSwaps: true }, { allowNestedOobSwaps: false }]) {
it('handles oob target in web components with both inside shadow root and config ' + JSON.stringify(config), function() {
this.server.respondWith('GET', '/test', '<div hx-swap-oob="innerHTML:#oob-swap-target">new contents</div>Clicked')
class TestElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<button hx-get="/test" hx-target="next div">Click me!</button>
<div id="main-target"></div>
<div id="oob-swap-target">this should get swapped</div>
`
htmx.process(root) // Tell HTMX about this component's shadow DOM
}
}
var elementName = 'test-oobswap-inside-' + config.allowNestedOobSwaps
customElements.define(elementName, TestElement)
var div = make(`<div><div id="oob-swap-target">this should not get swapped</div><${elementName}/></div>`)
var badTarget = div.querySelector('#oob-swap-target')
var webComponent = div.querySelector(elementName)
var btn = webComponent.shadowRoot.querySelector('button')
var goodTarget = webComponent.shadowRoot.querySelector('#oob-swap-target')
var mainTarget = webComponent.shadowRoot.querySelector('#main-target')
btn.click()
this.server.respond()
should.equal(mainTarget.textContent, 'Clicked')
should.equal(goodTarget.textContent, 'new contents')
should.equal(badTarget.textContent, 'this should not get swapped')
})
}
for (const config of [{ allowNestedOobSwaps: true }, { allowNestedOobSwaps: false }]) {
it('handles oob target in web components with main target outside web component config ' + JSON.stringify(config), function() {
this.server.respondWith('GET', '/test', '<div hx-swap-oob="innerHTML:#oob-swap-target">new contents</div>Clicked')
class TestElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<button hx-get="/test" hx-target="global #main-target">Click me!</button>
<div id="main-target"></div>
<div id="oob-swap-target">this should get swapped</div>
`
htmx.process(root) // Tell HTMX about this component's shadow DOM
}
}
var elementName = 'test-oobswap-global-main-' + config.allowNestedOobSwaps
customElements.define(elementName, TestElement)
var div = make(`<div><div id="main-target"></div><div id="oob-swap-target">this should not get swapped</div><${elementName}/></div>`)
var badTarget = div.querySelector('#oob-swap-target')
var webComponent = div.querySelector(elementName)
var btn = webComponent.shadowRoot.querySelector('button')
var goodTarget = webComponent.shadowRoot.querySelector('#oob-swap-target')
var mainTarget = div.querySelector('#main-target')
btn.click()
this.server.respond()
should.equal(mainTarget.textContent, 'Clicked')
should.equal(goodTarget.textContent, 'new contents')
should.equal(badTarget.textContent, 'this should not get swapped')
})
}
for (const config of [{ allowNestedOobSwaps: true }, { allowNestedOobSwaps: false }]) {
it('handles global oob target in web components with main target inside web component config ' + JSON.stringify(config), function() {
this.server.respondWith('GET', '/test', '<div hx-swap-oob="innerHTML:global #oob-swap-target">new contents</div>Clicked')
class TestElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<button hx-get="/test" hx-target="next div">Click me!</button>
<div id="main-target"></div>
<div id="oob-swap-target">this should not get swapped</div>
`
htmx.process(root) // Tell HTMX about this component's shadow DOM
}
}
var elementName = 'test-oobswap-global-oob-' + config.allowNestedOobSwaps
customElements.define(elementName, TestElement)
var div = make(`<div><div id="main-target"></div><div id="oob-swap-target">this should get swapped</div><${elementName}/></div>`)
var webComponent = div.querySelector(elementName)
var badTarget = webComponent.shadowRoot.querySelector('#oob-swap-target')
var btn = webComponent.shadowRoot.querySelector('button')
var goodTarget = div.querySelector('#oob-swap-target')
var mainTarget = webComponent.shadowRoot.querySelector('#main-target')
btn.click()
this.server.respond()
should.equal(mainTarget.textContent, 'Clicked')
should.equal(goodTarget.textContent, 'new contents')
should.equal(badTarget.textContent, 'this should not get swapped')
})
}
})