Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from
Dec 12, 2024
Merged
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
97 changes: 70 additions & 27 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -1126,34 +1126,77 @@ var htmx = (function() {
* @returns {(Node|Window)[]}
*/
function querySelectorAllExt(elt, selector, global) {
Telroshan marked this conversation as resolved.
Show resolved Hide resolved
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
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 12 additions & 14 deletions test/attributes/hx-boost.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/attributes/hx-disabled-elt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
112 changes: 112 additions & 0 deletions test/attributes/hx-include.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!')
})
})
20 changes: 20 additions & 0 deletions test/attributes/hx-trigger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down