Skip to content

Commit 18ec83e

Browse files
support extended css selectors in hx-partial (#3924)
* support extended css selectors in hx-partial * fix empty hx-partial targeting cuasing empty swaps
1 parent 1606ea7 commit 18ec83e

3 files changed

Lines changed: 41 additions & 3 deletions

File tree

src/htmx.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,7 +1176,8 @@ var htmx = (() => {
11761176
if (targetSelector) {
11771177
this.__processScripts(templateElt.content);
11781178
let swapSpec = this.__parseSwapSpec(this.__attr(templateElt, 'hx-swap') || this.config.defaultSwap);
1179-
for (let target of document.querySelectorAll(targetSelector)) {
1179+
let targets = this.__findAllExt(ctx.sourceElement, targetSelector);
1180+
                        for (let target of targets.length ? targets : [null]) {
11801181
tasks.push({
11811182
type: 'partial',
11821183
fragment: templateElt.content.cloneNode(true),
@@ -1894,7 +1895,7 @@ var htmx = (() => {
18941895

18951896
__findAllExt(eltOrSelector, maybeSelector, thisAttr, global) {
18961897
let selector = maybeSelector ?? eltOrSelector;
1897-
let elt = maybeSelector ? this.__normalizeElement(eltOrSelector) : document;
1898+
let elt = maybeSelector ? (this.__normalizeElement(eltOrSelector) || document.body) : document;
18981899
if (selector.startsWith('global ')) {
18991900
return this.__findAllExt(elt, selector.slice(7), thisAttr, true);
19001901
}

test/tests/unit/swap.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,15 @@ describe('swap() unit tests', function() {
468468
find('#target_oob').textContent.should.equal("OOB Updated");
469469
})
470470

471+
it('does not swap main target when partial target is not found', async function () {
472+
createProcessedHTML("<div id='target'>Original</div>")
473+
await htmx.swap({
474+
"target":"#target",
475+
"text":"<hx-partial hx-target='#nonexistent' hx-swap='innerHTML'><div>Should Not Appear</div></hx-partial>"
476+
})
477+
find('#target').textContent.should.equal("Original");
478+
})
479+
471480
it('does not swap main target when only whitespace and partial present', async function () {
472481
createProcessedHTML("<div id='target'>Original</div><div id='target_oob'>OOB</div>")
473482
await htmx.swap({
@@ -683,6 +692,16 @@ describe('swap() unit tests', function() {
683692
target.textContent.should.equal('response')
684693
})
685694

695+
it('hx-partial hx-target resolves closest relative to sourceElement', async function () {
696+
createProcessedHTML("<ul><li id='item-1'>Item 1 <button id='btn'>Edit</button></li></ul>")
697+
await htmx.swap({
698+
target: find('#btn'),
699+
sourceElement: find('#btn'),
700+
text: "<hx-partial hx-target='closest li' hx-swap='innerHTML'>Updated</hx-partial>"
701+
})
702+
find('#item-1').innerText.should.equal('Updated')
703+
})
704+
686705
it('swaps partial to all elements matching a class selector', async function () {
687706
createProcessedHTML("<div class='target'>A</div><div class='target'>B</div>")
688707
await htmx.swap({"target":"#test-playground", "text":"<hx-partial hx-target='.target' hx-swap='innerHTML'>Updated</hx-partial>"})

www/src/content/reference/06-tags/01-hx-partial.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,30 @@ The `<hx-partial>` tag lets you update multiple elements from a single response,
2020

2121
## Attributes
2222

23-
- [`hx-target`](/reference/attributes/hx-target) - CSS selector for where to place content
23+
- [`hx-target`](/reference/attributes/hx-target) - Where to place content. Accepts any CSS selector or htmx extended selector, resolved relative to the element that triggered the request
2424
- `id` - Shorthand alternative to `hx-target`. Targets the element with that ID (e.g. `<hx-partial id="messages">` targets `#messages`)
2525
- [`hx-swap`](/reference/attributes/hx-swap) - Swap style (defaults to `innerHTML`)
2626

2727
Either `hx-target` or `id` is required. If both are present, `hx-target` takes precedence.
2828

29+
## Relative Targeting
30+
31+
`hx-target` supports the full htmx extended selector vocabulary — `closest`, `next`, `previous`, `find`, `findAll` — resolved relative to the element that triggered the request. This lets the server express structural intent without requiring stable IDs:
32+
33+
```html
34+
<!-- replace the list row containing the button that was clicked -->
35+
<hx-partial hx-target="closest li" hx-swap="outerHTML">
36+
<li>Updated item <button hx-post="/edit/2">Edit</button></li>
37+
</hx-partial>
38+
39+
<!-- update the error element after the input that triggered validation -->
40+
<hx-partial hx-target="next .error">
41+
<span class="error">Required</span>
42+
</hx-partial>
43+
```
44+
45+
Avoid targeting an ancestor that the main swap is also replacing — the partial would be swapping into a detached node.
46+
2947
## Responses Without Main Content
3048

3149
When a response contains only `<hx-partial>` tags (no main content), the main target is left untouched. See [Multi-Target Updates](/docs#choosing-between-them) for details.

0 commit comments

Comments
 (0)