Skip to content

Commit a331244

Browse files
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 <[email protected]> * Keep global as a first-position-only keyword * Wrapped selector syntax * Replace substring check by individual chars check * Fix format --------- Co-authored-by: MichaelWest22 <[email protected]>
1 parent 232667d commit a331244

File tree

5 files changed

+253
-41
lines changed

5 files changed

+253
-41
lines changed

src/htmx.js

Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,34 +1126,77 @@ var htmx = (function() {
11261126
* @returns {(Node|Window)[]}
11271127
*/
11281128
function querySelectorAllExt(elt, selector, global) {
1129-
elt = resolveTarget(elt)
1130-
if (selector.indexOf('closest ') === 0) {
1131-
return [closest(asElement(elt), normalizeSelector(selector.slice(8)))]
1132-
} else if (selector.indexOf('find ') === 0) {
1133-
return [find(asParentNode(elt), normalizeSelector(selector.slice(5)))]
1134-
} else if (selector === 'next') {
1135-
return [asElement(elt).nextElementSibling]
1136-
} else if (selector.indexOf('next ') === 0) {
1137-
return [scanForwardQuery(elt, normalizeSelector(selector.slice(5)), !!global)]
1138-
} else if (selector === 'previous') {
1139-
return [asElement(elt).previousElementSibling]
1140-
} else if (selector.indexOf('previous ') === 0) {
1141-
return [scanBackwardsQuery(elt, normalizeSelector(selector.slice(9)), !!global)]
1142-
} else if (selector === 'document') {
1143-
return [document]
1144-
} else if (selector === 'window') {
1145-
return [window]
1146-
} else if (selector === 'body') {
1147-
return [document.body]
1148-
} else if (selector === 'root') {
1149-
return [getRootNode(elt, !!global)]
1150-
} else if (selector === 'host') {
1151-
return [(/** @type ShadowRoot */(elt.getRootNode())).host]
1152-
} else if (selector.indexOf('global ') === 0) {
1129+
if (selector.indexOf('global ') === 0) {
11531130
return querySelectorAllExt(elt, selector.slice(7), true)
1154-
} else {
1155-
return toArray(asParentNode(getRootNode(elt, !!global)).querySelectorAll(normalizeSelector(selector)))
11561131
}
1132+
1133+
elt = resolveTarget(elt)
1134+
1135+
const parts = []
1136+
{
1137+
let chevronsCount = 0
1138+
let offset = 0
1139+
for (let i = 0; i < selector.length; i++) {
1140+
const char = selector[i]
1141+
if (char === ',' && chevronsCount === 0) {
1142+
parts.push(selector.substring(offset, i))
1143+
offset = i + 1
1144+
continue
1145+
}
1146+
if (char === '<') {
1147+
chevronsCount++
1148+
} else if (char === '/' && i < selector.length - 1 && selector[i + 1] === '>') {
1149+
chevronsCount--
1150+
}
1151+
}
1152+
if (offset < selector.length) {
1153+
parts.push(selector.substring(offset))
1154+
}
1155+
}
1156+
1157+
const result = []
1158+
const unprocessedParts = []
1159+
while (parts.length > 0) {
1160+
const selector = normalizeSelector(parts.shift())
1161+
let item
1162+
if (selector.indexOf('closest ') === 0) {
1163+
item = closest(asElement(elt), normalizeSelector(selector.substr(8)))
1164+
} else if (selector.indexOf('find ') === 0) {
1165+
item = find(asParentNode(elt), normalizeSelector(selector.substr(5)))
1166+
} else if (selector === 'next' || selector === 'nextElementSibling') {
1167+
item = asElement(elt).nextElementSibling
1168+
} else if (selector.indexOf('next ') === 0) {
1169+
item = scanForwardQuery(elt, normalizeSelector(selector.substr(5)), !!global)
1170+
} else if (selector === 'previous' || selector === 'previousElementSibling') {
1171+
item = asElement(elt).previousElementSibling
1172+
} else if (selector.indexOf('previous ') === 0) {
1173+
item = scanBackwardsQuery(elt, normalizeSelector(selector.substr(9)), !!global)
1174+
} else if (selector === 'document') {
1175+
item = document
1176+
} else if (selector === 'window') {
1177+
item = window
1178+
} else if (selector === 'body') {
1179+
item = document.body
1180+
} else if (selector === 'root') {
1181+
item = getRootNode(elt, !!global)
1182+
} else if (selector === 'host') {
1183+
item = (/** @type ShadowRoot */(elt.getRootNode())).host
1184+
} else {
1185+
unprocessedParts.push(selector)
1186+
}
1187+
1188+
if (item) {
1189+
result.push(item)
1190+
}
1191+
}
1192+
1193+
if (unprocessedParts.length > 0) {
1194+
const standardSelector = unprocessedParts.join(',')
1195+
const rootNode = asParentNode(getRootNode(elt, !!global))
1196+
result.push(...toArray(rootNode.querySelectorAll(standardSelector)))
1197+
}
1198+
1199+
return result
11571200
}
11581201

11591202
/**
@@ -2327,7 +2370,7 @@ var htmx = (function() {
23272370
path = getDocument().location.href
23282371
}
23292372
if (verb === 'get' && path.includes('?')) {
2330-
path = path.replace(/\?[^#]+/, '');
2373+
path = path.replace(/\?[^#]+/, '')
23312374
}
23322375
}
23332376
triggerSpecs.forEach(function(triggerSpec) {

test/attributes/hx-boost.js

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,17 +157,16 @@ describe('hx-boost attribute', function() {
157157
})
158158

159159
it('form get with no action properly clears existing parameters on submit', function() {
160-
161160
/// add a foo=bar to the current url
162-
var path = location.href;
163-
if (!path.includes("foo=bar")) {
164-
if (!path.includes("?")) {
165-
path += "?foo=bar";
161+
var path = location.href
162+
if (!path.includes('foo=bar')) {
163+
if (!path.includes('?')) {
164+
path += '?foo=bar'
166165
} else {
167-
path += "&foo=bar";
166+
path += '&foo=bar'
168167
}
169168
}
170-
history.replaceState({ htmx: true }, '', path);
169+
history.replaceState({ htmx: true }, '', path)
171170

172171
this.server.respondWith('GET', /\/*/, function(xhr) {
173172
// foo should not be present because the form is a get with no action
@@ -183,17 +182,16 @@ describe('hx-boost attribute', function() {
183182
})
184183

185184
it('form get with an empty action properly clears existing parameters on submit', function() {
186-
187185
/// add a foo=bar to the current url
188-
var path = location.href;
189-
if (!path.includes("foo=bar")) {
190-
if (!path.includes("?")) {
191-
path += "?foo=bar";
186+
var path = location.href
187+
if (!path.includes('foo=bar')) {
188+
if (!path.includes('?')) {
189+
path += '?foo=bar'
192190
} else {
193-
path += "&foo=bar";
191+
path += '&foo=bar'
194192
}
195193
}
196-
history.replaceState({ htmx: true }, '', path);
194+
history.replaceState({ htmx: true }, '', path)
197195

198196
this.server.respondWith('GET', /\/*/, function(xhr) {
199197
// foo should not be present because the form is a get with no action

test/attributes/hx-disabled-elt.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,43 @@ describe('hx-disabled-elt attribute', function() {
9292
div.innerHTML.should.equal('Loaded!')
9393
btn.hasAttribute('disabled').should.equal(false)
9494
})
95+
96+
it('hx-disabled-elt supports multiple extended selectors', function() {
97+
this.server.respondWith('GET', '/test', 'Clicked!')
98+
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>')
99+
var i1 = byId('i1')
100+
var b2 = byId('b2')
101+
102+
i1.hasAttribute('disabled').should.equal(false)
103+
b2.hasAttribute('disabled').should.equal(false)
104+
105+
b2.click()
106+
i1.hasAttribute('disabled').should.equal(true)
107+
b2.hasAttribute('disabled').should.equal(true)
108+
109+
this.server.respond()
110+
111+
i1.hasAttribute('disabled').should.equal(false)
112+
b2.hasAttribute('disabled').should.equal(false)
113+
})
114+
115+
it('closest/find/next/previous handle nothing to find without exception', function() {
116+
this.server.respondWith('GET', '/test', 'Clicked!')
117+
var btn1 = make('<button hx-get="/test" hx-disabled-elt="closest input">Click Me!</button>')
118+
var btn2 = make('<button hx-get="/test" hx-disabled-elt="find input">Click Me!</button>')
119+
var btn3 = make('<button hx-get="/test" hx-disabled-elt="next input">Click Me!</button>')
120+
var btn4 = make('<button hx-get="/test" hx-disabled-elt="previous input">Click Me!</button>')
121+
btn1.click()
122+
btn1.hasAttribute('disabled').should.equal(false)
123+
this.server.respond()
124+
btn2.click()
125+
btn2.hasAttribute('disabled').should.equal(false)
126+
this.server.respond()
127+
btn3.click()
128+
btn3.hasAttribute('disabled').should.equal(false)
129+
this.server.respond()
130+
btn4.click()
131+
btn4.hasAttribute('disabled').should.equal(false)
132+
this.server.respond()
133+
})
95134
})

test/attributes/hx-include.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,116 @@ describe('hx-include attribute', function() {
224224
this.server.respond()
225225
btn.innerHTML.should.equal('Clicked!')
226226
})
227+
228+
it('Multiple extended selectors can be used in hx-include', function() {
229+
this.server.respondWith('POST', '/include', function(xhr) {
230+
var params = getParameters(xhr)
231+
params.i1.should.equal('test')
232+
params.i2.should.equal('foo')
233+
params.i3.should.equal('bar')
234+
params.i4.should.equal('test2')
235+
xhr.respond(200, {}, 'Clicked!')
236+
})
237+
make('<input name="i4" value="test2" id="i4"/>' +
238+
'<div id="i">' +
239+
'<input name="i1" value="test"/>' +
240+
'<input name="i2" value="foo"/>' +
241+
'<button id="btn" hx-post="/include" hx-include="closest div, next input, #i4"></button>' +
242+
'</div>' +
243+
'<input name="i3" value="bar"/>')
244+
var btn = byId('btn')
245+
btn.click()
246+
this.server.respond()
247+
btn.innerHTML.should.equal('Clicked!')
248+
})
249+
250+
it('hx-include processes extended selector in between standard selectors', function() {
251+
this.server.respondWith('POST', '/include', function(xhr) {
252+
var params = getParameters(xhr)
253+
params.i1.should.equal('test')
254+
should.equal(params.i2, undefined)
255+
params.i3.should.equal('bar')
256+
params.i4.should.equal('test2')
257+
xhr.respond(200, {}, 'Clicked!')
258+
})
259+
make('<input name="i4" value="test2" id="i4"/>' +
260+
'<div id="i">' +
261+
'<input name="i1" value="test" id="i1"/>' +
262+
'<input name="i2" value="foo"/>' +
263+
'<button id="btn" hx-post="/include" hx-include="#i1, next input, #i4"></button>' +
264+
'</div>' +
265+
'<input name="i3" value="bar"/>')
266+
var btn = byId('btn')
267+
btn.click()
268+
this.server.respond()
269+
btn.innerHTML.should.equal('Clicked!')
270+
})
271+
272+
it('hx-include processes nested standard selectors correctly', function() {
273+
this.server.respondWith('POST', '/include', function(xhr) {
274+
var params = getParameters(xhr)
275+
params.i1.should.equal('test')
276+
params.i2.should.equal('foo')
277+
params.i3.should.equal('bar')
278+
should.equal(params.i4, undefined)
279+
should.equal(params.i5, undefined)
280+
xhr.respond(200, {}, 'Clicked!')
281+
})
282+
make('<input name="i4" value="test2" id="i4"/>' +
283+
'<div id="i">' +
284+
'<input name="i1" value="test" id="i1"/>' +
285+
'<input name="i2" value="foo"/>' +
286+
'<input name="i5" value="test"/>' +
287+
'<button id="btn" hx-post="/include" hx-include="next input, #i > :is([name=\'i1\'], [name=\'i2\'])"></button>' +
288+
'</div>' +
289+
'<input name="i3" value="bar"/>')
290+
var btn = byId('btn')
291+
btn.click()
292+
this.server.respond()
293+
btn.innerHTML.should.equal('Clicked!')
294+
})
295+
296+
it('hx-include processes wrapped next/previous selectors correctly', function() {
297+
this.server.respondWith('POST', '/include', function(xhr) {
298+
var params = getParameters(xhr)
299+
should.equal(params.i1, undefined)
300+
params.i2.should.equal('foo')
301+
params.i3.should.equal('bar')
302+
should.equal(params.i4, undefined)
303+
should.equal(params.i5, undefined)
304+
xhr.respond(200, {}, 'Clicked!')
305+
})
306+
make('<input name="i4" value="test2" id="i4"/>' +
307+
'<div id="i">' +
308+
'<input name="i1" value="test" id="i1"/>' +
309+
'<input name="i2" value="foo"/>' +
310+
'<button id="btn" hx-post="/include" hx-include="next <#nonexistent, input/>, previous <#i5, [name=\'i2\'], #i4/>"></button>' +
311+
'</div>' +
312+
'<input name="i3" value="bar"/>' +
313+
'<input name="i5" value="test"/>')
314+
var btn = byId('btn')
315+
btn.click()
316+
this.server.respond()
317+
btn.innerHTML.should.equal('Clicked!')
318+
})
319+
320+
it('hx-include processes wrapped closest selector correctly', function() {
321+
this.server.respondWith('POST', '/include', function(xhr) {
322+
var params = getParameters(xhr)
323+
should.equal(params.i1, undefined)
324+
params.i2.should.equal('bar')
325+
xhr.respond(200, {}, 'Clicked!')
326+
})
327+
make('<section>' +
328+
'<input name="i1" value="foo"/>' +
329+
'<div>' +
330+
'<input name="i2" value="bar"/>' +
331+
'<button id="btn" hx-post="/include" hx-include="closest <section, div/>"></button>' +
332+
'</div>' +
333+
'</section>')
334+
var btn = byId('btn')
335+
btn.click()
336+
this.server.respond()
337+
btn.innerHTML.should.equal('Clicked!')
338+
})
227339
})

test/attributes/hx-trigger.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,26 @@ describe('hx-trigger attribute', function() {
657657
div1.innerHTML.should.equal('Requests: 2')
658658
})
659659

660+
it('from clause works with multiple extended selectors', function() {
661+
var requests = 0
662+
this.server.respondWith('GET', '/test', function(xhr) {
663+
requests++
664+
xhr.respond(200, {}, 'Requests: ' + requests)
665+
})
666+
make('<button id="btn" type="button">Click me</button>' +
667+
'<div hx-trigger="click from:(previous button, next a)" hx-target="#a1" hx-get="/test"></div>' +
668+
'<a id="a1">Requests: 0</a>')
669+
var btn = byId('btn')
670+
var a1 = byId('a1')
671+
a1.innerHTML.should.equal('Requests: 0')
672+
btn.click()
673+
this.server.respond()
674+
a1.innerHTML.should.equal('Requests: 1')
675+
a1.click()
676+
this.server.respond()
677+
a1.innerHTML.should.equal('Requests: 2')
678+
})
679+
660680
it('event listeners can filter on target', function() {
661681
var requests = 0
662682
this.server.respondWith('GET', '/test', function(xhr) {

0 commit comments

Comments
 (0)