mirror of
https://github.com/bigskysoftware/htmx.git
synced 2025-09-26 20:40:41 +00:00
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
commit
1537833ae0
107
src/htmx.js
107
src/htmx.js
@ -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))
|
||||
|
@ -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!')
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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>')
|
||||
})
|
||||
})
|
||||
|
@ -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')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
@ -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!')
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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!')
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user