Allow CSS selectors with whitespace in hx-trigger (#1913)

* Allow CSS selectors with whitespace in `hx-trigger`

Parsing of `hx-trigger` scans for whitespace, so if a CSS selector is used that contains whitespace, e.g. `form input`, a syntax error is raised.
A workaround is implemented by allowing such a CSS selector to be wrapped in either curly braces or parentheses.

* Add explanation whitespace in CSS selector to docs

* Tests for CSS selectors containing whitespace

* Use faster RegEx test, remove redundant variable declarations

* Added Descendant Combinator support to `root` and `target` modifiers

* Add missing semicolon

* Tests for descendant combinators in `root` and `target` modifiers

* Improve descendant combinator test coverage
This commit is contained in:
Jonathan Rietveld
2023-11-16 21:45:46 +01:00
committed by GitHub
parent 6a9a861ad9
commit 7ef95e8963
3 changed files with 90 additions and 12 deletions

View File

@@ -1146,6 +1146,8 @@ return (function () {
var SYMBOL_CONT = /[_$a-zA-Z0-9]/;
var STRINGISH_START = ['"', "'", "/"];
var NOT_WHITESPACE = /[^\s]/;
var COMBINED_SELECTOR_START = /[{(]/;
var COMBINED_SELECTOR_END = /[})]/;
function tokenizeString(str) {
var tokens = [];
var position = 0;
@@ -1234,6 +1236,18 @@ return (function () {
return result;
}
function consumeCSSSelector(tokens) {
var result;
if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) {
tokens.shift();
result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim();
tokens.shift();
} else {
result = consumeUntil(tokens, WHITESPACE_OR_COMMA);
}
return result;
}
var INPUT_SELECTOR = 'input, textarea, select';
/**
@@ -1282,29 +1296,33 @@ return (function () {
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA);
if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") {
tokens.shift();
var selector = consumeUntil(
tokens,
WHITESPACE_OR_COMMA
)
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
if (COMBINED_SELECTOR_START.test(tokens[0])) {
var from_arg = consumeCSSSelector(tokens);
} else {
var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA);
if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") {
tokens.shift();
var selector = consumeCSSSelector(tokens);
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
}
}
}
triggerSpec.from = from_arg;
} else if (token === "target" && tokens[0] === ":") {
tokens.shift();
triggerSpec.target = consumeUntil(tokens, WHITESPACE_OR_COMMA);
triggerSpec.target = consumeCSSSelector(tokens);
} else if (token === "throttle" && tokens[0] === ":") {
tokens.shift();
triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "queue" && tokens[0] === ":") {
tokens.shift();
triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else if ((token === "root" || token === "threshold") && tokens[0] === ":") {
} else if (token === "root" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeCSSSelector(tokens);
} else if (token === "threshold" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else {

View File

@@ -895,5 +895,64 @@ describe("hx-trigger attribute", function(){
form.innerHTML.should.equal("Called!");
})
it("correctly handles CSS descendant combinators", function(){
this.server.respondWith("GET", "/test", "Clicked!");
var outer = make(`
<div>
<div id='outer'>
<div id='first'>
<div id='inner'></div>
</div>
<div id='second' hx-get='/test' hx-trigger='click from:previous (#outer div)'>Unclicked.</div>
</div>
<div id='other' hx-get='/test' hx-trigger='click from:(div #inner)'>Unclicked.</div>
</div>
`);
var inner = byId("inner");
var second = byId("second");
var other = byId("other");
second.innerHTML.should.equal("Unclicked.");
other.innerHTML.should.equal("Unclicked.");
inner.click();
this.server.respond();
second.innerHTML.should.equal("Clicked!");
other.innerHTML.should.equal("Clicked!");
})
it('correctly handles CSS descendant combinators in modifier target', function() {
this.server.respondWith('GET', '/test', 'Called');
document.addEventListener('htmx:syntax:error', function(evt) {
chai.assert.fail('htmx:syntax:error');
});
make('<div class="d1"><a id="a1" class="a1">Click me</a><a id="a2" class="a2">Click me</a></div>');
var div = make('<div hx-trigger="click from:body target:(.d1 .a2)" hx-get="/test">Not Called</div>');
byId('a1').click();
this.server.respond();
div.innerHTML.should.equal("Not Called");
byId('a2').click();
this.server.respond();
div.innerHTML.should.equal("Called");
});
it('correctly handles CSS descendant combinators in modifier root', function() {
this.server.respondWith('GET', '/test', 'Called');
document.addEventListener('htmx:syntax:error', function(evt) {
chai.assert.fail('htmx:syntax:error');
});
make('<div hx-trigger="intersect root:{form input}" hx-get="/test">Not Called</div>');
});
})

View File

@@ -153,3 +153,4 @@ The AJAX request can be triggered via JavaScript [`htmx.trigger()`](@/api.md#tri
* `hx-trigger` is not inherited
* `hx-trigger` can be used without an AJAX request, in which case it will only fire the `htmx:trigger` event
* In order to pass a CSS selector that contains whitespace (e.g. `form input`) to the `from`- or `target`-modifier, surround the selector in parentheses or curly brackets (e.g. `from:(form input)` or `from:nearest (form input)`)