diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 60c9ee79d..50f508757 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -637,7 +637,8 @@ if (p === Symbol.iterator) return () => elts.values(); if (p === 'q') return s => { let out = new Set(); - for (let e of elts) for (let r of makeQ(e, e)(s).arr()) out.add(r); + let isPositional = /^(next|previous)\s/.test(s); + for (let e of elts) for (let r of makeQ(e, isPositional ? e.parentElement : e)(s).arr()) out.add(r); return qProxy([...out]); }; if (p === 'trigger') return (t, d, b) => { elts.forEach(e => htmx.trigger(e, t, d, b)); return proxy; }; diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 07b7d7fc0..6d6f28faf 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1201,6 +1201,120 @@ describe('hx-live extension', function () { proxy.count.should.equal(1); }); + // ------------------------------------------------------------------------- + // chained q: directional scoping (option 4 behaviour) + // ------------------------------------------------------------------------- + + it('chained q: next finds sibling within same parent', function() { + playground().innerHTML = ` +
+
One
+
Two
+
Three
+
Four
+
+ `; + let proxy = htmx.live.q('#parent .el.selected').q('next .el'); + proxy.count.should.equal(1); + proxy.textContent.should.equal('Three'); + }); + + it('chained q: previous finds sibling within same parent', function() { + playground().innerHTML = ` +
+
One
+
Two
+
Three
+
Four
+
+ `; + let proxy = htmx.live.q('#parent .el.selected').q('previous .el'); + proxy.count.should.equal(1); + proxy.textContent.should.equal('One'); + }); + + it('chained q: next returns empty when anchor is last sibling', function() { + playground().innerHTML = ` +
+
One
+
Two
+
Three
+
+ `; + htmx.live.q('#parent .el.last').q('next .el').count.should.equal(0); + }); + + it('chained q: previous returns empty when anchor is first sibling', function() { + playground().innerHTML = ` +
+
One
+
Two
+
Three
+
+ `; + htmx.live.q('#parent .el.first').q('previous .el').count.should.equal(0); + }); + + it('chained q: next does not escape to a different parent', function() { + // Two groups in separate parents. next .item from #s1 searches within + // #s1's parent (#group1) only — cannot reach .item inside #group2. + playground().innerHTML = ` +
+
+
+
+
B
+
+ `; + let proxy = htmx.live.q('#s1').q('next .item'); + proxy.count.should.equal(0); + }); + + it('chained q: first scopes inside each matched element', function() { + playground().innerHTML = ` +
ab
+
cd
+ `; + let proxy = htmx.live.q('section').q('first .i'); + proxy.count.should.equal(2); + proxy.arr().map(e => e.textContent).should.deep.equal(['a', 'c']); + }); + + it('chained q: last scopes inside each matched element', function() { + playground().innerHTML = ` +
ab
+
cd
+ `; + let proxy = htmx.live.q('section').q('last .i'); + proxy.count.should.equal(2); + proxy.arr().map(e => e.textContent).should.deep.equal(['b', 'd']); + }); + + it('chained q: first with in this also scopes inside each element', function() { + playground().innerHTML = ` +
ab
+
cd
+ `; + // explicit "in this" should give the same result as the default scoped behaviour + let proxy = htmx.live.q('section').q('first .i in this'); + proxy.count.should.equal(2); + proxy.arr().map(e => e.textContent).should.deep.equal(['a', 'c']); + }); + + it('chained q: next across multiple rows returns each successor', function() { + playground().innerHTML = ` +
+
Row 1
+
Row 2
+
Row 3
+
+ `; + // 3 rows: row1→row2, row2→row3, row3→nothing. Deduped result = row2, row3. + let proxy = htmx.live.q('#rows .row').q('next .row'); + proxy.count.should.equal(2); + proxy.arr().map(e => e.textContent.trim()).should.deep.equal(['Row 2', 'Row 3']); + }); + it('q is available in hx-on scope and bound to element', function() { playground().innerHTML = '
tgt
'; htmx.process(playground()); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index dcfe7a1e3..11d041615 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -322,7 +322,7 @@ q('button').click() ```js q('first .foo') // first match in document order -q('last .foo') // last match +q('last .foo') // last match in document order q('next .foo') // first match after this element q('previous .foo') // closest match before this element q('closest .foo') // nearest ancestor matching .foo @@ -332,14 +332,29 @@ q('.foo in this') // restrict to the current element `next`, `previous`, and `closest` resolve against `this`. They require an expression scope with a current element. For an ancestor lookup that works anywhere, use [`closest(selector)`](#closestselector). -**Chaining** +**Chaining** -`.q(...)` on a proxy re-runs the grammar with each element as the anchor: +`.q(...)` on a proxy re-runs the grammar with each element as the positional anchor. + +`first` and `last` scope inside each matched element: + +```js +q('section').q('first .item') // first .item inside each section +q('section').q('last .item') // last .item inside each section +``` + +`next` and `previous` search within the matched element's parent, so they find siblings and their descendants but cannot reach elements under a different parent: + +```js +q('.row').q('next .row') // each row's next sibling row +q('#parent .el.selected').q('next .el') // next .el after the selected one +q('#parent .el.selected').q('previous .el') // previous .el before the selected one +``` + +`closest` finds the nearest ancestor of each matched element: ```js q('.error').q('closest .field') // surrounding .field of each .error -q('section').q('first .item') // first .item per section -q('.row').q('next .row') // each row's successor ``` For plain descendant queries, CSS is shorter: `q('.card .title')` and `q('.card').q('.title')` are equivalent. Use chaining when you need a directional per matched element.