Support multiple extended selectors for hx-include, hx-trigger from, and hx-disabled-elt (#2902)

* Initial suggestion (squashed)

Support multiple extended selectors for hx-include

Additional test for nested standard selector

Add @MichaelWest22 hx-disabled-elt multiple selector test

Add hx-trigger `from` test with multiple extended selectors

Simplify

Include #2915 fix

Update htmx.js

Split for readability

Don't apply global to previous selectors

Rewrite loop, restore global recursive call, minimize diff

Use break for better readability

Co-Authored-By: MichaelWest22 <12867972+MichaelWest22@users.noreply.github.com>

* Keep global as a first-position-only keyword

* Wrapped selector syntax

* Replace substring check by individual chars check

* Fix format

---------

Co-authored-by: MichaelWest22 <12867972+MichaelWest22@users.noreply.github.com>
This commit is contained in:
Vincent
2024-12-12 19:12:01 +01:00
committed by GitHub
parent 232667d2c6
commit a331244923
5 changed files with 253 additions and 41 deletions

View File

@@ -1126,34 +1126,77 @@ var htmx = (function() {
* @returns {(Node|Window)[]}
*/
function querySelectorAllExt(elt, selector, global) {
elt = resolveTarget(elt)
if (selector.indexOf('closest ') === 0) {
return [closest(asElement(elt), normalizeSelector(selector.slice(8)))]
} else if (selector.indexOf('find ') === 0) {
return [find(asParentNode(elt), normalizeSelector(selector.slice(5)))]
} else if (selector === 'next') {
return [asElement(elt).nextElementSibling]
} else if (selector.indexOf('next ') === 0) {
return [scanForwardQuery(elt, normalizeSelector(selector.slice(5)), !!global)]
} else if (selector === 'previous') {
return [asElement(elt).previousElementSibling]
} else if (selector.indexOf('previous ') === 0) {
return [scanBackwardsQuery(elt, normalizeSelector(selector.slice(9)), !!global)]
} else if (selector === 'document') {
return [document]
} else if (selector === 'window') {
return [window]
} else if (selector === 'body') {
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) {
if (selector.indexOf('global ') === 0) {
return querySelectorAllExt(elt, selector.slice(7), true)
} else {
return toArray(asParentNode(getRootNode(elt, !!global)).querySelectorAll(normalizeSelector(selector)))
}
elt = resolveTarget(elt)
const parts = []
{
let chevronsCount = 0
let offset = 0
for (let i = 0; i < selector.length; i++) {
const char = selector[i]
if (char === ',' && chevronsCount === 0) {
parts.push(selector.substring(offset, i))
offset = i + 1
continue
}
if (char === '<') {
chevronsCount++
} else if (char === '/' && i < selector.length - 1 && selector[i + 1] === '>') {
chevronsCount--
}
}
if (offset < selector.length) {
parts.push(selector.substring(offset))
}
}
const result = []
const unprocessedParts = []
while (parts.length > 0) {
const selector = normalizeSelector(parts.shift())
let item
if (selector.indexOf('closest ') === 0) {
item = closest(asElement(elt), normalizeSelector(selector.substr(8)))
} else if (selector.indexOf('find ') === 0) {
item = find(asParentNode(elt), normalizeSelector(selector.substr(5)))
} else if (selector === 'next' || selector === 'nextElementSibling') {
item = asElement(elt).nextElementSibling
} else if (selector.indexOf('next ') === 0) {
item = scanForwardQuery(elt, normalizeSelector(selector.substr(5)), !!global)
} else if (selector === 'previous' || selector === 'previousElementSibling') {
item = asElement(elt).previousElementSibling
} else if (selector.indexOf('previous ') === 0) {
item = scanBackwardsQuery(elt, normalizeSelector(selector.substr(9)), !!global)
} else if (selector === 'document') {
item = document
} else if (selector === 'window') {
item = window
} else if (selector === 'body') {
item = document.body
} else if (selector === 'root') {
item = getRootNode(elt, !!global)
} else if (selector === 'host') {
item = (/** @type ShadowRoot */(elt.getRootNode())).host
} else {
unprocessedParts.push(selector)
}
if (item) {
result.push(item)
}
}
if (unprocessedParts.length > 0) {
const standardSelector = unprocessedParts.join(',')
const rootNode = asParentNode(getRootNode(elt, !!global))
result.push(...toArray(rootNode.querySelectorAll(standardSelector)))
}
return result
}
/**
@@ -2327,7 +2370,7 @@ var htmx = (function() {
path = getDocument().location.href
}
if (verb === 'get' && path.includes('?')) {
path = path.replace(/\?[^#]+/, '');
path = path.replace(/\?[^#]+/, '')
}
}
triggerSpecs.forEach(function(triggerSpec) {

View File

@@ -157,17 +157,16 @@ describe('hx-boost attribute', function() {
})
it('form get with no action properly clears existing parameters on submit', function() {
/// add a foo=bar to the current url
var path = location.href;
if (!path.includes("foo=bar")) {
if (!path.includes("?")) {
path += "?foo=bar";
var path = location.href
if (!path.includes('foo=bar')) {
if (!path.includes('?')) {
path += '?foo=bar'
} else {
path += "&foo=bar";
path += '&foo=bar'
}
}
history.replaceState({ htmx: true }, '', path);
history.replaceState({ htmx: true }, '', path)
this.server.respondWith('GET', /\/*/, function(xhr) {
// foo should not be present because the form is a get with no action
@@ -183,17 +182,16 @@ describe('hx-boost attribute', function() {
})
it('form get with an empty action properly clears existing parameters on submit', function() {
/// add a foo=bar to the current url
var path = location.href;
if (!path.includes("foo=bar")) {
if (!path.includes("?")) {
path += "?foo=bar";
var path = location.href
if (!path.includes('foo=bar')) {
if (!path.includes('?')) {
path += '?foo=bar'
} else {
path += "&foo=bar";
path += '&foo=bar'
}
}
history.replaceState({ htmx: true }, '', path);
history.replaceState({ htmx: true }, '', path)
this.server.respondWith('GET', /\/*/, function(xhr) {
// foo should not be present because the form is a get with no action

View File

@@ -92,4 +92,43 @@ describe('hx-disabled-elt attribute', function() {
div.innerHTML.should.equal('Loaded!')
btn.hasAttribute('disabled').should.equal(false)
})
it('hx-disabled-elt supports multiple extended selectors', function() {
this.server.respondWith('GET', '/test', 'Clicked!')
var form = make('<form hx-get="/test" hx-disabled-elt="find input[type=\'text\'], find button" hx-swap="none"><input id="i1" type="text" placeholder="Type here..."><button id="b2" type="submit">Send</button></form>')
var i1 = byId('i1')
var b2 = byId('b2')
i1.hasAttribute('disabled').should.equal(false)
b2.hasAttribute('disabled').should.equal(false)
b2.click()
i1.hasAttribute('disabled').should.equal(true)
b2.hasAttribute('disabled').should.equal(true)
this.server.respond()
i1.hasAttribute('disabled').should.equal(false)
b2.hasAttribute('disabled').should.equal(false)
})
it('closest/find/next/previous handle nothing to find without exception', function() {
this.server.respondWith('GET', '/test', 'Clicked!')
var btn1 = make('<button hx-get="/test" hx-disabled-elt="closest input">Click Me!</button>')
var btn2 = make('<button hx-get="/test" hx-disabled-elt="find input">Click Me!</button>')
var btn3 = make('<button hx-get="/test" hx-disabled-elt="next input">Click Me!</button>')
var btn4 = make('<button hx-get="/test" hx-disabled-elt="previous input">Click Me!</button>')
btn1.click()
btn1.hasAttribute('disabled').should.equal(false)
this.server.respond()
btn2.click()
btn2.hasAttribute('disabled').should.equal(false)
this.server.respond()
btn3.click()
btn3.hasAttribute('disabled').should.equal(false)
this.server.respond()
btn4.click()
btn4.hasAttribute('disabled').should.equal(false)
this.server.respond()
})
})

View File

@@ -224,4 +224,116 @@ describe('hx-include attribute', function() {
this.server.respond()
btn.innerHTML.should.equal('Clicked!')
})
it('Multiple extended selectors can be used in hx-include', function() {
this.server.respondWith('POST', '/include', function(xhr) {
var params = getParameters(xhr)
params.i1.should.equal('test')
params.i2.should.equal('foo')
params.i3.should.equal('bar')
params.i4.should.equal('test2')
xhr.respond(200, {}, 'Clicked!')
})
make('<input name="i4" value="test2" id="i4"/>' +
'<div id="i">' +
'<input name="i1" value="test"/>' +
'<input name="i2" value="foo"/>' +
'<button id="btn" hx-post="/include" hx-include="closest div, next input, #i4"></button>' +
'</div>' +
'<input name="i3" value="bar"/>')
var btn = byId('btn')
btn.click()
this.server.respond()
btn.innerHTML.should.equal('Clicked!')
})
it('hx-include processes extended selector in between standard selectors', function() {
this.server.respondWith('POST', '/include', function(xhr) {
var params = getParameters(xhr)
params.i1.should.equal('test')
should.equal(params.i2, undefined)
params.i3.should.equal('bar')
params.i4.should.equal('test2')
xhr.respond(200, {}, 'Clicked!')
})
make('<input name="i4" value="test2" id="i4"/>' +
'<div id="i">' +
'<input name="i1" value="test" id="i1"/>' +
'<input name="i2" value="foo"/>' +
'<button id="btn" hx-post="/include" hx-include="#i1, next input, #i4"></button>' +
'</div>' +
'<input name="i3" value="bar"/>')
var btn = byId('btn')
btn.click()
this.server.respond()
btn.innerHTML.should.equal('Clicked!')
})
it('hx-include processes nested standard selectors correctly', function() {
this.server.respondWith('POST', '/include', function(xhr) {
var params = getParameters(xhr)
params.i1.should.equal('test')
params.i2.should.equal('foo')
params.i3.should.equal('bar')
should.equal(params.i4, undefined)
should.equal(params.i5, undefined)
xhr.respond(200, {}, 'Clicked!')
})
make('<input name="i4" value="test2" id="i4"/>' +
'<div id="i">' +
'<input name="i1" value="test" id="i1"/>' +
'<input name="i2" value="foo"/>' +
'<input name="i5" value="test"/>' +
'<button id="btn" hx-post="/include" hx-include="next input, #i > :is([name=\'i1\'], [name=\'i2\'])"></button>' +
'</div>' +
'<input name="i3" value="bar"/>')
var btn = byId('btn')
btn.click()
this.server.respond()
btn.innerHTML.should.equal('Clicked!')
})
it('hx-include processes wrapped next/previous selectors correctly', function() {
this.server.respondWith('POST', '/include', function(xhr) {
var params = getParameters(xhr)
should.equal(params.i1, undefined)
params.i2.should.equal('foo')
params.i3.should.equal('bar')
should.equal(params.i4, undefined)
should.equal(params.i5, undefined)
xhr.respond(200, {}, 'Clicked!')
})
make('<input name="i4" value="test2" id="i4"/>' +
'<div id="i">' +
'<input name="i1" value="test" id="i1"/>' +
'<input name="i2" value="foo"/>' +
'<button id="btn" hx-post="/include" hx-include="next <#nonexistent, input/>, previous <#i5, [name=\'i2\'], #i4/>"></button>' +
'</div>' +
'<input name="i3" value="bar"/>' +
'<input name="i5" value="test"/>')
var btn = byId('btn')
btn.click()
this.server.respond()
btn.innerHTML.should.equal('Clicked!')
})
it('hx-include processes wrapped closest selector correctly', function() {
this.server.respondWith('POST', '/include', function(xhr) {
var params = getParameters(xhr)
should.equal(params.i1, undefined)
params.i2.should.equal('bar')
xhr.respond(200, {}, 'Clicked!')
})
make('<section>' +
'<input name="i1" value="foo"/>' +
'<div>' +
'<input name="i2" value="bar"/>' +
'<button id="btn" hx-post="/include" hx-include="closest <section, div/>"></button>' +
'</div>' +
'</section>')
var btn = byId('btn')
btn.click()
this.server.respond()
btn.innerHTML.should.equal('Clicked!')
})
})

View File

@@ -657,6 +657,26 @@ describe('hx-trigger attribute', function() {
div1.innerHTML.should.equal('Requests: 2')
})
it('from clause works with multiple extended selectors', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {
requests++
xhr.respond(200, {}, 'Requests: ' + requests)
})
make('<button id="btn" type="button">Click me</button>' +
'<div hx-trigger="click from:(previous button, next a)" hx-target="#a1" hx-get="/test"></div>' +
'<a id="a1">Requests: 0</a>')
var btn = byId('btn')
var a1 = byId('a1')
a1.innerHTML.should.equal('Requests: 0')
btn.click()
this.server.respond()
a1.innerHTML.should.equal('Requests: 1')
a1.click()
this.server.respond()
a1.innerHTML.should.equal('Requests: 2')
})
it('event listeners can filter on target', function() {
var requests = 0
this.server.respondWith('GET', '/test', function(xhr) {