Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,8 @@ var htmx = (function() {
* @returns {DocumentFragmentWithTitle}
*/
function makeFragment(response) {
// convert <hx-*> custom tags to <template hx type="*"> so they survive HTML parsing
response = response.replace(/<hx-([a-z]+)((?:\s[^>]*)?)>/gi, '<template hx type="$1"$2>').replace(/<\/hx-[a-z]+>/gi, '</template>')
// strip head tag to determine shape of response we are dealing with
const responseWithNoHead = response.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i, '')
const startTag = getStartTag(responseWithNoHead)
Expand Down Expand Up @@ -1841,6 +1843,54 @@ var htmx = (function() {
}
}

/**
* @param {DocumentFragment|ParentNode} fragment
* @param {HtmxSettleInfo} settleInfo
* @param {Element} sourceElement
* @returns {boolean}
*/
function findAndSwapPartials(fragment, settleInfo, sourceElement) {
var hxTemplates = findAll(fragment, 'template[hx]')
forEach(hxTemplates, function(template) {
var type = getRawAttribute(template, 'type')
if (type === 'partial') {
var targetSelector = getAttributeValue(template, 'hx-target') ||
(template.id ? '#' + CSS.escape(template.id) : null)
if (targetSelector) {
var swapOverride = getAttributeValue(template, 'hx-swap')
var swapSpec = getSwapSpecification(template, swapOverride)
var targets = querySelectorAllExt(sourceElement || getDocument().body, targetSelector, false)
if (targets.length === 0) {
triggerErrorEvent(getDocument().body, 'htmx:partialErrorNoTarget', { template, targetSelector, sourceElement })
}
forEach(targets, function(target) {
target = asElement(target)
if (target) {
var fragment = template.content.cloneNode(true)
var beforeSwapDetails = { shouldSwap: true, target, fragment }
if (!triggerEvent(target, 'htmx:partialBeforeSwap', beforeSwapDetails)) return
target = beforeSwapDetails.target
if (beforeSwapDetails.shouldSwap) {
swap(target, beforeSwapDetails.fragment, swapSpec, {
contextElement: target,
afterSwapCallback: function() {
forEach(settleInfo.elts, function(elt) {
triggerEvent(elt, 'htmx:partialAfterSwap', beforeSwapDetails)
})
}
})
}
}
})
}
} else {
triggerEvent(getDocument().body, 'htmx:processTemplate', { type, template, settleInfo, sourceElement })
}
template.parentNode.removeChild(template)
})
return hxTemplates.length > 0
}

/**
* @param {DocumentFragment} fragment
* @param {HtmxSettleInfo} settleInfo
Expand Down Expand Up @@ -1906,7 +1956,7 @@ var htmx = (function() {
target.textContent = content
// Otherwise, make the fragment and process it
} else {
let fragment = makeFragment(content)
let fragment = typeof content === 'string' ? makeFragment(content) : content

settleInfo.title = swapOptions.title || fragment.title
if (swapOptions.historyRequest) {
Expand Down Expand Up @@ -1938,6 +1988,8 @@ var htmx = (function() {
template.remove()
}
})
// partial swaps — after oob, before main swap
var hasPartials = findAndSwapPartials(fragment, settleInfo, swapOptions.contextElement || asElement(target))

// normal swap
if (swapOptions.select) {
Expand All @@ -1948,7 +2000,12 @@ var htmx = (function() {
fragment = newFragment
}
handlePreservedElements(fragment)
swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo)
// if the response contained only <hx-partial> tags and nothing else, skip the main swap
if (hasPartials && !fragment.childElementCount && !fragment.textContent.trim()) {
settleInfo.elts = [asElement(target)]
} else {
swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo)
}
restorePreservedElements()
}

Expand Down
191 changes: 191 additions & 0 deletions test/attributes/hx-partial.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
describe('hx-partial', function() {
beforeEach(function() {
this.server = makeServer()
clearWorkArea()
})
afterEach(function() {
this.server.restore()
clearWorkArea()
})

it('swaps partial with explicit hx-target', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='#d1'>Partial</hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('Partial')
})

it('swaps partial with custom swap style', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='#d1' hx-swap='beforeend'>Appended</hx-partial>")
make('<div id="d1">Existing</div>')
var div = make('<div hx-get="/test">click me</div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('ExistingAppended')
})

it('swaps main target and partial target when both present', function() {
this.server.respondWith('GET', '/test', "<div>Main</div><hx-partial hx-target='#d2' hx-swap='innerHTML'><span>Partial</span></hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d2">Old</div>')
div.click()
this.server.respond()
div.innerHTML.should.equal('<div>Main</div>')
byId('d2').innerHTML.should.equal('<span>Partial</span>')
})

it('does not swap main target when response contains only partial', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='#d2'>Updated</hx-partial>")
var div = make('<div hx-get="/test">Original</div>')
make('<div id="d2">OOB</div>')
div.click()
this.server.respond()
div.innerHTML.should.equal('Original')
byId('d2').innerHTML.should.equal('Updated')
})

it('does not swap main target when only whitespace and partial present', function() {
this.server.respondWith('GET', '/test', "\n <hx-partial hx-target='#d1'>Updated</hx-partial> \n")
var div = make('<div hx-get="/test">Original</div>')
make('<div id="d1">Old</div>')
div.click()
this.server.respond()
div.innerHTML.should.equal('Original')
byId('d1').innerHTML.should.equal('Updated')
})

it('swaps both targets when empty element and partial present', function() {
this.server.respondWith('GET', '/test', "<p></p><hx-partial hx-target='#d1'>Updated</hx-partial>")
var div = make('<div hx-get="/test">Original</div>')
make('<div id="d1">Old</div>')
div.click()
this.server.respond()
div.querySelector('p').should.not.equal(null)
byId('d1').innerHTML.should.equal('Updated')
})

it('swaps both targets when plain text and partial present', function() {
this.server.respondWith('GET', '/test', "Hello<hx-partial hx-target='#d1'>Updated</hx-partial>")
var div = make('<div hx-get="/test">Original</div>')
make('<div id="d1">Old</div>')
div.click()
this.server.respond()
div.textContent.should.equal('Hello')
byId('d1').innerHTML.should.equal('Updated')
})

it('swaps partial to all elements matching a class selector', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='.target' hx-swap='innerHTML'>Updated</hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1" class="target">A</div>')
make('<div id="d2" class="target">B</div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('Updated')
byId('d2').innerHTML.should.equal('Updated')
})

it('resolves closest selector relative to triggering element', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='closest li' hx-swap='innerHTML'>Updated</hx-partial>")
var li = make('<ul><li id="item-1">Item 1 <button id="btn" hx-get="/test">Edit</button></li></ul>')
byId('btn').click()
this.server.respond()
byId('item-1').innerHTML.should.equal('Updated')
})

it('executes script in partial', function() {
window.testVar = 0
this.server.respondWith('GET', '/test', "<hx-partial hx-target='#d1'><script>window.testVar = 42<\/script></hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
window.testVar.should.equal(42)
delete window.testVar
})

it('template fallback form works identically', function() {
this.server.respondWith('GET', '/test', '<template hx type="partial" hx-target="#d1">Partial</template>')
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('Partial')
})

it('multiple partials in one response all execute', function() {
this.server.respondWith('GET', '/test',
"<hx-partial hx-target='#d1'>One</hx-partial><hx-partial hx-target='#d2'>Two</hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
make('<div id="d2"></div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('One')
byId('d2').innerHTML.should.equal('Two')
})

it('swaps partial using element id as implicit target', function() {
this.server.respondWith('GET', '/test', "<hx-partial id='d1'>ByID</hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('ByID')
})

it('partial with no matching target does not throw', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='#nonexistent'>Content</hx-partial>")
var div = make('<div hx-get="/test">Original</div>')
div.click()
this.server.respond()
div.innerHTML.should.equal('Original')
})

it('swaps partial content independently to each matched target', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='.target' hx-swap='innerHTML'><b>Updated</b></hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1" class="target">A</div>')
make('<div id="d2" class="target">B</div>')
make('<div id="d3" class="target">C</div>')
div.click()
this.server.respond()
byId('d1').innerHTML.should.equal('<b>Updated</b>')
byId('d2').innerHTML.should.equal('<b>Updated</b>')
byId('d3').innerHTML.should.equal('<b>Updated</b>')
// each target must have its own node, not the same shared reference
byId('d1').querySelector('b').should.not.equal(byId('d2').querySelector('b'))
byId('d2').querySelector('b').should.not.equal(byId('d3').querySelector('b'))
})

it('fires htmx:processTemplate for unknown hx-xxx tags allowing custom handling', function() {
this.server.respondWith('GET', '/test', "<hx-toast message='Hello'></hx-toast>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
var fired = false
var detail = null
var listener = htmx.on('htmx:processTemplate', function(evt) {
fired = true
detail = evt.detail
byId('d1').textContent = detail.template.getAttribute('message')
})
div.click()
this.server.respond()
fired.should.equal(true)
detail.type.should.equal('toast')
byId('d1').textContent.should.equal('Hello')
htmx.off('htmx:processTemplate', listener)
})

it('partial initializes htmx attributes in swapped content', function() {
this.server.respondWith('GET', '/test', "<hx-partial hx-target='#d1'><button id='btn2' hx-get='/other'>Go</button></hx-partial>")
var div = make('<div hx-get="/test">click me</div>')
make('<div id="d1"></div>')
div.click()
this.server.respond()
should.exist(byId('btn2'))
byId('btn2').getAttribute('hx-get').should.equal('/other')
})
})
1 change: 1 addition & 0 deletions test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ <h2>Mocha Test Suite</h2>
<script src="attributes/hx-disinherit.js"></script>
<script src="attributes/hx-on-wildcard.js"></script>
<script src="attributes/hx-params.js"></script>
<script src="attributes/hx-partial.js"></script>
<script src="attributes/hx-patch.js"></script>
<script src="attributes/hx-post.js"></script>
<script src="attributes/hx-preserve.js"></script>
Expand Down
Loading
Loading