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

This commit is contained in:
Carson Gross 2024-10-02 19:58:30 -06:00
commit 1537833ae0
12 changed files with 509 additions and 51 deletions

View File

@ -337,22 +337,10 @@ var htmx = (function() {
return '[hx-' + verb + '], [data-hx-' + verb + ']'
}).join(', ')
const HEAD_TAG_REGEX = makeTagRegEx('head')
//= ===================================================================
// Utilities
//= ===================================================================
/**
* @param {string} tag
* @param {boolean} global
* @returns {RegExp}
*/
function makeTagRegEx(tag, global = false) {
return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`,
global ? 'gim' : 'im')
}
/**
* Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes.
*
@ -595,7 +583,7 @@ var htmx = (function() {
*/
function makeFragment(response) {
// strip head tag to determine shape of response we are dealing with
const responseWithNoHead = response.replace(HEAD_TAG_REGEX, '')
const responseWithNoHead = response.replace(/<head(\s[^>]*)?>.*?<\/head>/is, '')
const startTag = getStartTag(responseWithNoHead)
/** @type DocumentFragmentWithTitle */
let fragment
@ -695,7 +683,7 @@ var htmx = (function() {
* @property {boolean} [triggeredOnce]
* @property {number} [delayed]
* @property {number|null} [throttle]
* @property {string} [lastValue]
* @property {WeakMap<HtmxTriggerSpecification,WeakMap<EventTarget,string>>} [lastValue]
* @property {boolean} [loaded]
* @property {string} [path]
* @property {string} [verb]
@ -1161,6 +1149,8 @@ var htmx = (function() {
return [document.body]
} else if (selector === 'root') {
return [getRootNode(elt, !!global)]
} else if (selector === 'host') {
return [(/** @type ShadowRoot */(elt.getRootNode())).host]
} else if (selector.indexOf('global ') === 0) {
return querySelectorAllExt(elt, selector.slice(7), true)
} else {
@ -1417,9 +1407,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'
@ -1431,8 +1423,10 @@ var htmx = (function() {
} else {
swapStyle = oobValue
}
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,
@ -1450,7 +1444,9 @@ var htmx = (function() {
target = beforeSwapDetails.target // allow re-targeting
if (beforeSwapDetails.shouldSwap) {
handlePreservedElements(fragment)
swapWithStyle(swapStyle, target, target, fragment, settleInfo)
restorePreservedElements()
}
forEach(settleInfo.elts, function(elt) {
triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails)
@ -1479,7 +1475,7 @@ var htmx = (function() {
}
/**
* @param {DocumentFragment} fragment
* @param {DocumentFragment|ParentNode} fragment
*/
function handlePreservedElements(fragment) {
forEach(findAll(fragment, '[hx-preserve], [data-hx-preserve]'), function(preservedElt) {
@ -1661,9 +1657,13 @@ var htmx = (function() {
/** @type {Node} */
let newElt
const eltBeforeNewContent = target.previousSibling
insertNodesBefore(parentElt(target), target, fragment, settleInfo)
const parentNode = parentElt(target)
if (!parentNode) { // when parent node disappears, we can't do anything
return
}
insertNodesBefore(parentNode, target, fragment, settleInfo)
if (eltBeforeNewContent == null) {
newElt = parentElt(target).firstChild
newElt = parentNode.firstChild
} else {
newElt = eltBeforeNewContent.nextSibling
}
@ -1725,7 +1725,10 @@ var htmx = (function() {
*/
function swapDelete(target) {
cleanUpElement(target)
return parentElt(target).removeChild(target)
const parent = parentElt(target)
if (parent) {
return parent.removeChild(target)
}
}
/**
@ -1808,14 +1811,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')
@ -1839,6 +1843,7 @@ var htmx = (function() {
}
target = resolveTarget(target)
const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument()
// preserve focus and selection
const activeElt = document.activeElement
@ -1877,14 +1882,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()
}
@ -2171,8 +2176,8 @@ var htmx = (function() {
if (eventFilter) {
triggerSpec.eventFilter = eventFilter
}
consumeUntil(tokens, NOT_WHITESPACE)
while (tokens.length > 0 && tokens[0] !== ',') {
consumeUntil(tokens, NOT_WHITESPACE)
const token = tokens.shift()
if (token === 'changed') {
triggerSpec.changed = true
@ -2217,6 +2222,7 @@ var htmx = (function() {
} else {
triggerErrorEvent(elt, 'htmx:syntax:error', { token: tokens.shift() })
}
consumeUntil(tokens, NOT_WHITESPACE)
}
triggerSpecs.push(triggerSpec)
}
@ -2316,9 +2322,10 @@ var htmx = (function() {
} else {
const rawAttribute = getRawAttribute(elt, 'method')
verb = (/** @type HttpVerb */(rawAttribute ? rawAttribute.toLowerCase() : 'get'))
if (verb === 'get') {
}
path = getRawAttribute(elt, 'action')
if (verb === 'get' && path.includes('?')) {
path = path.replace(/\?[^#]+/, '')
}
}
triggerSpecs.forEach(function(triggerSpec) {
addEventListener(elt, function(node, evt) {
@ -2407,10 +2414,15 @@ var htmx = (function() {
}
// store the initial values of the elements, so we can tell if they change
if (triggerSpec.changed) {
if (!('lastValue' in elementData)) {
elementData.lastValue = new WeakMap()
}
eltsToListenOn.forEach(function(eltToListenOn) {
const eltToListenOnData = getInternalData(eltToListenOn)
if (!elementData.lastValue.has(triggerSpec)) {
elementData.lastValue.set(triggerSpec, new WeakMap())
}
// @ts-ignore value will be undefined for non-input elements, which is fine
eltToListenOnData.lastValue = eltToListenOn.value
elementData.lastValue.get(triggerSpec).set(eltToListenOn, eltToListenOn.value)
})
}
forEach(eltsToListenOn, function(eltToListenOn) {
@ -2452,13 +2464,14 @@ var htmx = (function() {
}
}
if (triggerSpec.changed) {
const eltToListenOnData = getInternalData(eltToListenOn)
const node = event.target
// @ts-ignore value will be undefined for non-input elements, which is fine
const value = eltToListenOn.value
if (eltToListenOnData.lastValue === value) {
const value = node.value
const lastValue = elementData.lastValue.get(triggerSpec)
if (lastValue.has(node) && lastValue.get(node) === value) {
return
}
eltToListenOnData.lastValue = value
lastValue.set(node, value)
}
if (elementData.delayed) {
clearTimeout(elementData.delayed)
@ -2836,12 +2849,6 @@ var htmx = (function() {
triggerEvent(elt, 'htmx:beforeProcessNode')
// @ts-ignore value will be undefined for non-input elements, which is fine
if (elt.value) {
// @ts-ignore
nodeData.lastValue = elt.value
}
const triggerSpecs = getTriggerSpecs(elt)
const hasExplicitHttpAction = processVerbs(elt, nodeData, triggerSpecs)
@ -3269,16 +3276,18 @@ var htmx = (function() {
* @param {Element[]} disabled
*/
function removeRequestIndicators(indicators, disabled) {
forEach(indicators.concat(disabled), function(ele) {
const internalData = getInternalData(ele)
internalData.requestCount = (internalData.requestCount || 1) - 1
})
forEach(indicators, function(ic) {
const internalData = getInternalData(ic)
internalData.requestCount = (internalData.requestCount || 0) - 1
if (internalData.requestCount === 0) {
ic.classList.remove.call(ic.classList, htmx.config.requestClass)
}
})
forEach(disabled, function(disabledElement) {
const internalData = getInternalData(disabledElement)
internalData.requestCount = (internalData.requestCount || 0) - 1
if (internalData.requestCount === 0) {
disabledElement.removeAttribute('disabled')
disabledElement.removeAttribute('data-disabled-by-htmx')
@ -3891,16 +3900,22 @@ var htmx = (function() {
if (context) {
if (context instanceof Element || typeof context === 'string') {
return issueAjaxRequest(verb, path, null, null, {
targetOverride: resolveTarget(context),
targetOverride: resolveTarget(context) || DUMMY_ELT,
returnPromise: true
})
} else {
let resolvedTarget = resolveTarget(context.target)
// If target is supplied but can't resolve OR both target and source can't be resolved
// then use DUMMY_ELT to abort the request with htmx:targetError to avoid it replacing body by mistake
if ((context.target && !resolvedTarget) || (!resolvedTarget && !resolveTarget(context.source))) {
resolvedTarget = DUMMY_ELT
}
return issueAjaxRequest(verb, path, resolveTarget(context.source), context.event,
{
handler: context.handler,
headers: context.headers,
values: context.values,
targetOverride: resolveTarget(context.target),
targetOverride: resolvedTarget,
swapOverride: context.swap,
select: context.select,
returnPromise: true
@ -3962,7 +3977,7 @@ var htmx = (function() {
const formData = new FormData()
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key].forEach === 'function') {
if (obj[key] && typeof obj[key].forEach === 'function') {
obj[key].forEach(function(v) { formData.append(key, v) })
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Blob)) {
formData.append(key, JSON.stringify(obj[key]))
@ -4055,7 +4070,7 @@ var htmx = (function() {
return false
}
target.delete(name)
if (typeof value.forEach === 'function') {
if (value && typeof value.forEach === 'function') {
value.forEach(function(v) { target.append(name, v) })
} else if (typeof value === 'object' && !(value instanceof Blob)) {
target.append(name, JSON.stringify(value))

View File

@ -118,4 +118,29 @@ describe('hx-boost attribute', function() {
this.server.respond()
btn.innerHTML.should.equal('Boosted!')
})
it('form get w/ search params in action property excludes search params', function() {
this.server.respondWith('GET', /\/test.*/, function(xhr) {
should.equal(undefined, getParameters(xhr).foo)
xhr.respond(200, {}, 'Boosted!')
})
var div = make('<div hx-target="this" hx-boost="true"><form id="f1" action="/test?foo=bar" method="get"><button id="b1">Submit</button></form></div>')
var btn = byId('b1')
btn.click()
this.server.respond()
div.innerHTML.should.equal('Boosted!')
})
it('form post w/ query params in action property uses full url', function() {
this.server.respondWith('POST', /\/test.*/, function(xhr) {
should.equal(undefined, getParameters(xhr).foo)
xhr.respond(200, {}, 'Boosted!')
})
var div = make('<div hx-target="this" hx-boost="true"><form id="f1" action="/test?foo=bar" method="post"><button id="b1">Submit</button></form></div>')
var btn = byId('b1')
btn.click()
this.server.respond()
div.innerHTML.should.equal('Boosted!')
})
})

View File

@ -80,4 +80,16 @@ describe('hx-disabled-elt attribute', function() {
b2.hasAttribute('disabled').should.equal(false)
b3.hasAttribute('disabled').should.equal(false)
})
it('load trigger does not prevent disabled element working', function() {
this.server.respondWith('GET', '/test', 'Loaded!')
var div1 = make('<div id="d1" hx-get="/test" hx-disabled-elt="#b1" hx-trigger="load">Load Me!</div><button id="b1">Demo</button>')
var div = byId('d1')
var btn = byId('b1')
div.innerHTML.should.equal('Load Me!')
btn.hasAttribute('disabled').should.equal(true)
this.server.respond()
div.innerHTML.should.equal('Loaded!')
btn.hasAttribute('disabled').should.equal(false)
})
})

View File

@ -34,4 +34,38 @@ describe('hx-preserve attribute', function() {
byId('d1').innerHTML.should.equal('Old Content')
byId('d2').innerHTML.should.equal('New Content')
})
it('preserved element should not be swapped if it is part of a oob swap', function() {
this.server.respondWith('GET', '/test', "Normal Content<div id='d2' hx-swap-oob='true'><div id='d3' hx-preserve>New oob Content</div><div id='d4'>New oob Content</div></div>")
var div1 = make("<div id='d1' hx-get='/test'>Click Me!</div>")
var div2 = make("<div id='d2'><div id='d3' hx-preserve>Old Content</div></div>")
div1.click()
this.server.respond()
byId('d1').innerHTML.should.equal('Normal Content')
byId('d3').innerHTML.should.equal('Old Content')
byId('d4').innerHTML.should.equal('New oob Content')
})
it('preserved element should not be swapped if it is part of a hx-select-oob swap', function() {
this.server.respondWith('GET', '/test', "Normal Content<div id='d2'><div id='d3' hx-preserve>New oob Content</div><div id='d4'>New oob Content</div></div>")
var div1 = make("<div id='d1' hx-get='/test' hx-select-oob='#d2'>Click Me!</div>")
var div2 = make("<div id='d2'><div id='d3' hx-preserve>Old Content</div></div>")
div1.click()
this.server.respond()
byId('d1').innerHTML.should.equal('Normal Content')
byId('d3').innerHTML.should.equal('Old Content')
byId('d4').innerHTML.should.equal('New oob Content')
})
it('preserved element should relocated unchanged if it is part of a oob swap targeting a different loction', function() {
this.server.respondWith('GET', '/test', "Normal Content<div id='d2' hx-swap-oob='innerHTML:#d5'><div id='d3' hx-preserve>New oob Content</div><div id='d4'>New oob Content</div></div>")
var div1 = make("<div id='d1' hx-get='/test'>Click Me!</div>")
var div2 = make("<div id='d2'><div id='d3' hx-preserve>Old Content</div></div>")
var div5 = make("<div id='d5'></div>")
div1.click()
this.server.respond()
byId('d1').innerHTML.should.equal('Normal Content')
byId('d2').innerHTML.should.equal('')
byId('d5').innerHTML.should.equal('<div id="d3" hx-preserve="">Old Content</div><div id="d4">New oob Content</div>')
})
})

View File

@ -66,6 +66,28 @@ describe('hx-swap-oob attribute', function() {
})
}
it('handles remvoing hx-swap-oob tag', function() {
this.server.respondWith('GET', '/test', "Clicked<div id='d1' data-hx-swap-oob='true'>Swapped3</div>")
var div = make('<div data-hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
div.innerHTML.should.equal('Clicked')
byId('d1').innerHTML.should.equal('Swapped3')
byId('d1').hasAttribute('hx-swap-oob').should.equal(false)
})
it('handles remvoing data-hx-swap-oob tag', function() {
this.server.respondWith('GET', '/test', "Clicked<div id='d1' data-hx-swap-oob='true'>Swapped3</div>")
var div = make('<div data-hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
div.innerHTML.should.equal('Clicked')
byId('d1').innerHTML.should.equal('Swapped3')
byId('d1').hasAttribute('data-hx-swap-oob').should.equal(false)
})
it('handles no id match properly', function() {
this.server.respondWith('GET', '/test', "Clicked<div id='d1' hx-swap-oob='true'>Swapped2</div>")
var div = make('<div hx-get="/test">click me</div>')
@ -155,6 +177,7 @@ describe('hx-swap-oob attribute', function() {
it('swaps into all targets that match the selector (outerHTML)', function() {
var oobSwapContent = '<div class="new-target" hx-swap-oob="outerHTML:.target">Swapped9</div>'
var finalContent = '<div class="new-target">Swapped9</div>'
this.server.respondWith('GET', '/test', '<div>Clicked</div>' + oobSwapContent)
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"><div>No swap</div></div>')
@ -163,8 +186,8 @@ describe('hx-swap-oob attribute', function() {
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('<div>No swap</div>')
byId('d2').innerHTML.should.equal(oobSwapContent)
byId('d3').innerHTML.should.equal(oobSwapContent)
byId('d2').innerHTML.should.equal(finalContent)
byId('d3').innerHTML.should.equal(finalContent)
})
it('oob swap delete works properly', function() {
@ -237,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')
})
}
})

View File

@ -64,6 +64,7 @@ describe('hx-trigger attribute', function() {
div.innerHTML.should.equal('Requests: 1')
})
// This test and the next one should be kept in sync.
it('changed modifier works along from clause with two inputs', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {
@ -106,6 +107,92 @@ describe('hx-trigger attribute', function() {
div.innerHTML.should.equal('Requests: 2')
})
// This test and the previous one should be kept in sync.
it('changed modifier counts each triggerspec separately', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {
requests++
xhr.respond(200, {}, 'Requests: ' + requests)
})
var input1 = make('<input type="text"/>')
var input2 = make('<input type="text"/>')
make('<div hx-trigger="click changed from:input" hx-target="#d1" hx-get="/test"></div>')
make('<div hx-trigger="click changed from:input" hx-target="#d1" hx-get="/test"></div>')
var div = make('<div id="d1"></div>')
input1.click()
this.server.respond()
div.innerHTML.should.equal('')
input2.click()
this.server.respond()
div.innerHTML.should.equal('')
input1.value = 'bar'
input2.click()
this.server.respond()
div.innerHTML.should.equal('')
input1.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 2')
input1.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 2')
input2.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 2')
input2.value = 'foo'
input1.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 2')
input2.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 4')
})
it('separate changed modifier works along from clause with two inputs', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {
requests++
xhr.respond(200, {}, 'Requests: ' + requests)
})
var input1 = make('<input type="text"/>')
var input2 = make('<input type="text"/>')
make('<div hx-trigger="click changed from:input:nth-child(1), click changed from:input:nth-child(2)" hx-target="#d1" hx-get="/test"></div>')
var div = make('<div id="d1"></div>')
input1.click()
this.server.respond()
div.innerHTML.should.equal('')
input2.click()
this.server.respond()
div.innerHTML.should.equal('')
input1.value = 'bar'
input2.click()
this.server.respond()
div.innerHTML.should.equal('')
input1.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 1')
input1.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 1')
input2.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 1')
input2.value = 'foo'
input1.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 1')
input2.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 2')
})
it('once modifier works', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {
@ -1079,4 +1166,19 @@ describe('hx-trigger attribute', function() {
htmx.config.triggerSpecsCache = initialCacheConfig
})
it('handles spaces at the end of trigger specs', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {
requests++
xhr.respond(200, {}, 'Requests: ' + requests)
})
var div = make('<div hx-trigger="load , click consume " hx-get="/test">Requests: 0</div>')
div.innerHTML.should.equal('Requests: 0')
this.server.respond()
div.innerHTML.should.equal('Requests: 1')
div.click()
this.server.respond()
div.innerHTML.should.equal('Requests: 2')
})
})

View File

@ -314,4 +314,15 @@ describe('hx-vals attribute', function() {
}
calledEvent.should.equal(true)
})
it('hx-vals works with null values', function() {
this.server.respondWith('POST', '/vars', function(xhr) {
var params = getParameters(xhr)
params.i1.should.equal('null')
xhr.respond(200, {}, 'Clicked!')
})
var div = make("<div hx-post='/vars' hx-vals='{\"i1\": null }'></div>")
div.click()
this.server.respond()
div.innerHTML.should.equal('Clicked!')
})
})

View File

@ -213,6 +213,71 @@ describe('Core htmx API test', function() {
div.innerHTML.should.equal('foo!')
})
it('ajax api does not fall back to body when target invalid', function() {
this.server.respondWith('GET', '/test', 'foo!')
var div = make("<div id='d1'></div>")
htmx.ajax('GET', '/test', '#d2')
this.server.respond()
document.body.innerHTML.should.not.equal('foo!')
})
it('ajax api fails when target invalid', function(done) {
this.server.respondWith('GET', '/test', 'foo!')
var div = make("<div id='d1'></div>")
htmx.ajax('GET', '/test', '#d2').then(
(value) => {
},
(reason) => {
done()
}
)
this.server.respond()
div.innerHTML.should.equal('')
})
it('ajax api fails when target invalid even if source set', function(done) {
this.server.respondWith('GET', '/test', 'foo!')
var div = make("<div id='d1'></div>")
htmx.ajax('GET', '/test', {
source: div,
target: '#d2'
}).then(
(value) => {
},
(reason) => {
done()
}
)
this.server.respond()
div.innerHTML.should.equal('')
})
it('ajax api fails when source invalid and no target set', function(done) {
this.server.respondWith('GET', '/test', 'foo!')
var div = make("<div id='d1'></div>")
htmx.ajax('GET', '/test', {
source: '#d2'
}).then(
(value) => {
},
(reason) => {
done()
}
)
this.server.respond()
div.innerHTML.should.equal('')
})
it('ajax api falls back to targeting source if target not set', function() {
this.server.respondWith('GET', '/test', 'foo!')
var div = make("<div id='d1'></div>")
htmx.ajax('GET', '/test', {
source: div
})
this.server.respond()
div.innerHTML.should.equal('foo!')
})
it('ajax api works with swapSpec', function() {
this.server.respondWith('GET', '/test', "<p class='test'>foo!</p>")
var div = make("<div><div id='target'></div></div>")
@ -403,4 +468,28 @@ describe('Core htmx API test', function() {
output.innerHTML.should.be.equal('<div>Swapped!</div>')
oobDiv.innerHTML.should.be.equal('OOB Swapped!')
})
it('swap delete works when parent is removed', function() {
this.server.respondWith('DELETE', '/test', 'delete')
var parent = make('<div><div id="d1" hx-swap="delete" hx-delete="/test">click me</div></div>')
var div = htmx.find(parent, '#d1')
div.click()
div.remove()
parent.remove()
this.server.respond()
parent.children.length.should.equal(0)
})
it('swap outerHTML works when parent is removed', function() {
this.server.respondWith('GET', '/test', 'delete')
var parent = make('<div><div id="d1" hx-swap="outerHTML" hx-get="/test">click me</div></div>')
var div = htmx.find(parent, '#d1')
div.click()
div.remove()
parent.remove()
this.server.respond()
parent.children.length.should.equal(0)
})
})

View File

@ -280,6 +280,14 @@ describe('Core htmx Parameter Handling', function() {
vals.foo.should.equal('bar')
})
it('formdata works with null values', function() {
var form = make('<form hx-post="/test"><input name="foo" value="bar"/></form>')
var vals = htmx._('getInputValues')(form, 'get').values
function updateToNull() { vals.foo = null }
updateToNull.should.not.throw()
vals.foo.should.equal('null')
})
it('order of parameters follows order of input elements', function() {
this.server.respondWith('GET', '/test?foo=bar&bar=foo&foo=bar&foo2=bar2', function(xhr) {
xhr.respond(200, {}, 'Clicked!')

View File

@ -19,7 +19,9 @@ describe('Core htmx Shadow DOM Tests', function() {
afterEach(function() {
this.server.restore()
clearWorkArea()
getWorkArea().shadowRoot.innerHTML = ''
var workArea = getWorkArea()
if (!workArea.shadowRoot) workArea.attachShadow({ mode: 'open' })
workArea.shadowRoot.innerHTML = ''
})
// Locally redefine the `byId` and `make` functions to use shadow DOM
@ -67,6 +69,14 @@ describe('Core htmx Shadow DOM Tests', function() {
}
})
})
it('properly retrives shadow root host for extended selector', function() {
var div = make('<div hx-target="host"></div>')
htmx.defineExtension('test/shadowdom.js', {
init: function(api) {
api.getTarget(div).should.equal(getWorkArea())
}
})
})
// bootstrap test
it('issues a GET request on click and swaps content', function() {
@ -1313,4 +1323,34 @@ describe('Core htmx Shadow DOM Tests', function() {
window.foo.should.equal(true)
delete window.foo
})
it('can target shadow DOM Host and place content below web component', function() {
this.server.respondWith('GET', '/test', '<div id="r1">Clicked!</div>')
var btn = make('<button hx-get="/test" hx-target="host" hx-swap="afterend">Click Me!</button>')
btn.click()
this.server.respond()
var r1 = document.getElementById('r1')
r1.innerHTML.should.equal('Clicked!')
r1.remove()
})
it('can target global id outside shadow DOM and place content', function() {
this.server.respondWith('GET', '/test', '<div id="r2">Clicked!</div>')
var btn = make('<button hx-get="/test" hx-target="global #work-area" hx-swap="beforebegin">Click Me!</button>')
btn.click()
this.server.respond()
var r2 = document.getElementById('r2')
r2.innerHTML.should.equal('Clicked!')
r2.remove()
})
it('can target shadow DOM Host with outerHTML swap and replace it', function() {
this.server.respondWith('GET', '/test', '<div id="work-area" hx-history-elt>Clicked!</div>')
var btn = make('<button hx-get="/test" hx-target="host" hx-swap="outerHTML">Click Me!</button>')
btn.click()
chai.expect(getWorkArea().shadowRoot).to.not.be.a('null')
this.server.respond()
getWorkArea().innerHTML.should.equal('Clicked!')
chai.expect(getWorkArea().shadowRoot).to.be.a('null')
})
})

View File

@ -19,7 +19,7 @@
</style>
<script src="../../../src/htmx.js" hx-preserve="true"></script>
<script src="../../../src/ext/head-support.js" hx-preserve="true"></script>
<script src="https://unpkg.com/htmx-ext-head-support@2.0.0/head-support.js" hx-preserve="true"></script>
</head>
<body hx-ext="head-support" hx-boost="true">
<header hx-push-url="false" hx-target="main" hx-swap="innerHTML">
@ -38,4 +38,4 @@
</ul>
</main>
</body>
</html>
</html>

View File

@ -10,6 +10,18 @@ The response requires an element with the same `id`, but its type and other attr
Note that some elements cannot unfortunately be preserved properly, such as `<input type="text">` (focus and caret position are lost), iframes or certain types of videos. To tackle some of these cases we recommend the [morphdom extension](https://github.com/bigskysoftware/htmx-extensions/blob/main/src/morphdom-swap/README.md), which does a more elaborate DOM
reconciliation.
## OOB Swap Usage
You can include `hx-preserve` in the inner response of a [hx-swap-oob](@/attributes/hx-swap-oob.md) and it will preserve the element unchanged during the out of band partial replacement as well. However, you cannot place `hx-preserve` on the same element as the `hx-swap-oob` is placed. For example, here is an oob response that replaces notify but leaves the retain div unchanged.
```html
<div id="notify" hx-swap-oob="true">
<p>The below content will not be changed</p>
<div id="retain" hx-preserve>Use the on-page contents</div>
</div>
```
## Notes
* `hx-preserve` is not inherited
* `hx-preserve` can cause elements to be relocated to a new location when swapping in a partial response