From b7b9e7a1caa1c41991e7edd28c8488a2ac6ed248 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 12 Sep 2024 18:29:46 +0200 Subject: [PATCH 1/5] 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> --- src/htmx.js | 76 ++++++++++++++++++++---------- test/attributes/hx-disabled-elt.js | 39 +++++++++++++++ test/attributes/hx-include.js | 68 ++++++++++++++++++++++++++ test/attributes/hx-trigger.js | 20 ++++++++ 4 files changed, 177 insertions(+), 26 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index d3411e2c4..6bcb0ba2a 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1127,33 +1127,57 @@ var htmx = (function() { */ 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) { - return querySelectorAllExt(elt, selector.slice(7), true) - } else { - return toArray(asParentNode(getRootNode(elt, !!global)).querySelectorAll(normalizeSelector(selector))) + + const parts = selector.split(',') + 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 if (selector.indexOf('global ') === 0) { + // Previous implementation of `global` only supported it at the first position and applied it to the entire selector string. + // For backward compatibility and to maintain logical consistency, we make it apply to everything that follows. + parts.unshift(selector.slice(7)) + result.push(...querySelectorAllExt(elt, parts.join(','), true)) + break + } 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 } /** diff --git a/test/attributes/hx-disabled-elt.js b/test/attributes/hx-disabled-elt.js index 66d8386bf..abdfa3066 100644 --- a/test/attributes/hx-disabled-elt.js +++ b/test/attributes/hx-disabled-elt.js @@ -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('
') + 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('') + var btn2 = make('') + var btn3 = make('') + var btn4 = make('') + 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() + }) }) diff --git a/test/attributes/hx-include.js b/test/attributes/hx-include.js index 299810ad0..31ccbbca5 100644 --- a/test/attributes/hx-include.js +++ b/test/attributes/hx-include.js @@ -224,4 +224,72 @@ 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('' + + '
' + + '' + + '' + + '' + + '
' + + '') + 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('' + + '
' + + '' + + '' + + '' + + '
' + + '') + 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('' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '') + var btn = byId('btn') + btn.click() + this.server.respond() + btn.innerHTML.should.equal('Clicked!') + }) }) diff --git a/test/attributes/hx-trigger.js b/test/attributes/hx-trigger.js index 704e9ec96..b673a8a51 100644 --- a/test/attributes/hx-trigger.js +++ b/test/attributes/hx-trigger.js @@ -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('' + + '
' + + 'Requests: 0') + 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) { From 4d09e92a87ec40af792976dd1897d63fb066db65 Mon Sep 17 00:00:00 2001 From: Vincent Date: Sun, 17 Nov 2024 22:29:41 +0100 Subject: [PATCH 2/5] Keep global as a first-position-only keyword --- src/htmx.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 6bcb0ba2a..13dea4ef6 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1126,6 +1126,10 @@ var htmx = (function() { * @returns {(Node|Window)[]} */ function querySelectorAllExt(elt, selector, global) { + if (selector.indexOf('global ') === 0) { + return querySelectorAllExt(elt, selector.slice(7), true) + } + elt = resolveTarget(elt) const parts = selector.split(',') @@ -1156,12 +1160,6 @@ var htmx = (function() { item = getRootNode(elt, !!global) } else if (selector === 'host') { item = (/** @type ShadowRoot */(elt.getRootNode())).host - } else if (selector.indexOf('global ') === 0) { - // Previous implementation of `global` only supported it at the first position and applied it to the entire selector string. - // For backward compatibility and to maintain logical consistency, we make it apply to everything that follows. - parts.unshift(selector.slice(7)) - result.push(...querySelectorAllExt(elt, parts.join(','), true)) - break } else { unprocessedParts.push(selector) } From f66f77b269ab1cbb9ddeb12f1af74ec8270be92b Mon Sep 17 00:00:00 2001 From: Vincent Date: Sun, 17 Nov 2024 23:11:14 +0100 Subject: [PATCH 3/5] Wrapped selector syntax --- src/htmx.js | 23 +++++++++++++++++- test/attributes/hx-include.js | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index 13dea4ef6..8b23060a2 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1132,7 +1132,28 @@ var htmx = (function() { elt = resolveTarget(elt) - const parts = selector.split(',') + 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 (selector.substring(i, i + 2) === '/>') { + chevronsCount-- + } + } + if (offset < selector.length) { + parts.push(selector.substring(offset)) + } + } + const result = [] const unprocessedParts = [] while (parts.length > 0) { diff --git a/test/attributes/hx-include.js b/test/attributes/hx-include.js index 31ccbbca5..502e78a1f 100644 --- a/test/attributes/hx-include.js +++ b/test/attributes/hx-include.js @@ -292,4 +292,48 @@ describe('hx-include attribute', function() { 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('' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '') + 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('
' + + '' + + '
' + + '' + + '' + + '
' + + '
') + var btn = byId('btn') + btn.click() + this.server.respond() + btn.innerHTML.should.equal('Clicked!') + }) }) From c3b1c5966b9581e880bae4f51aca0cdea2ae1d8c Mon Sep 17 00:00:00 2001 From: Vincent Date: Mon, 18 Nov 2024 09:32:11 +0100 Subject: [PATCH 4/5] Replace substring check by individual chars check --- src/htmx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index 8b23060a2..8be6c5821 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1145,7 +1145,7 @@ var htmx = (function() { } if (char === '<') { chevronsCount++ - } else if (selector.substring(i, i + 2) === '/>') { + } else if (char === '/' && i < selector.length - 1 && selector[i + 1] === '>') { chevronsCount-- } } From 7ef99a905c352bd3c4b5c09a1ef36cbe45320632 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 12 Dec 2024 09:06:33 +0100 Subject: [PATCH 5/5] Fix format --- src/htmx.js | 2 +- test/attributes/hx-boost.js | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 8be6c5821..eaa4d6c66 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -2370,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) { diff --git a/test/attributes/hx-boost.js b/test/attributes/hx-boost.js index 8f23c7413..e788dfa0a 100644 --- a/test/attributes/hx-boost.js +++ b/test/attributes/hx-boost.js @@ -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