Skip to content

Commit

Permalink
feat: [#1683] Adds support for subsequent sibling combinator to query…
Browse files Browse the repository at this point in the history
… selectors and match

* fix: [#1683] Fix not recognizing subsequent sibling combinator in QuerySelector

* chore: [#1683] Improves performance and adds support for matches

---------

Co-authored-by: David Ortner <[email protected]>
  • Loading branch information
karpiuMG and capricorn86 authored Jan 14, 2025
1 parent ecbf335 commit 3bb23c2
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 6 deletions.
62 changes: 58 additions & 4 deletions packages/happy-dom/src/query-selector/QuerySelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export default class QuerySelector {
const previousElementSibling = element.previousElementSibling;
if (previousElementSibling) {
previousElementSibling[PropertySymbol.affectsCache].push(cachedItem);

const match = this.matchSelector(
previousElementSibling,
selectorItems.slice(1),
Expand Down Expand Up @@ -419,6 +420,33 @@ export default class QuerySelector {
}
}
break;
case SelectorCombinatorEnum.subsequentSibling:
const siblingParentElement = element.parentElement;
if (siblingParentElement) {
const siblings = siblingParentElement[PropertySymbol.elementArray];
const index = siblings.indexOf(element);

siblingParentElement[PropertySymbol.affectsCache].push(cachedItem);

for (let i = index - 1; i >= 0; i--) {
const sibling = siblings[i];

sibling[PropertySymbol.affectsCache].push(cachedItem);

const match = this.matchSelector(
sibling,
selectorItems.slice(1),
cachedItem,
selectorItem,
priorityWeight + result.priorityWeight
);

if (match) {
return match;
}
}
}
break;
}
}

Expand Down Expand Up @@ -477,11 +505,12 @@ export default class QuerySelector {
} else {
switch (nextSelectorItem.combinator) {
case SelectorCombinatorEnum.adjacentSibling:
if (child.nextElementSibling) {
const nextElementSibling = child.nextElementSibling;
if (nextElementSibling) {
matched = matched.concat(
this.findAll(
rootElement,
[child.nextElementSibling],
[nextElementSibling],
selectorItems.slice(1),
cachedItem,
position
Expand All @@ -501,6 +530,15 @@ export default class QuerySelector {
)
);
break;
case SelectorCombinatorEnum.subsequentSibling:
const index = children.indexOf(child);
for (let j = index + 1; j < children.length; j++) {
const sibling = children[j];
matched = matched.concat(
this.findAll(rootElement, [sibling], selectorItems.slice(1), cachedItem, position)
);
}
break;
}
}
}
Expand Down Expand Up @@ -546,10 +584,11 @@ export default class QuerySelector {
} else {
switch (nextSelectorItem.combinator) {
case SelectorCombinatorEnum.adjacentSibling:
if (child.nextElementSibling) {
const nextElementSibling = child.nextElementSibling;
if (nextElementSibling) {
const match = this.findFirst(
rootElement,
[child.nextElementSibling],
[nextElementSibling],
selectorItems.slice(1),
cachedItem
);
Expand All @@ -570,6 +609,21 @@ export default class QuerySelector {
return match;
}
break;
case SelectorCombinatorEnum.subsequentSibling:
const index = children.indexOf(child);
for (let i = index + 1; i < children.length; i++) {
const sibling = children[i];
const match = this.findFirst(
rootElement,
[sibling],
selectorItems.slice(1),
cachedItem
);
if (match) {
return match;
}
}
break;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
enum SelectorCombinatorEnum {
descendant = 'descendant',
child = 'child',
adjacentSibling = 'adjacentSibling'
adjacentSibling = 'adjacentSibling',
subsequentSibling = 'subsequentSibling'
}

export default SelectorCombinatorEnum;
9 changes: 8 additions & 1 deletion packages/happy-dom/src/query-selector/SelectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import ISelectorPseudo from './ISelectorPseudo.js';
* Group 17: Combinator.
*/
const SELECTOR_REGEXP =
/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_\\:]+)\]|\[([a-zA-Z0-9-_\\:]+)\s*([~|^$*]{0,1})\s*=\s*["']{1}([^"']*)["']{1}\s*(s|i){0,1}\]|\[([a-zA-Z0-9-_]+)\s*([~|^$*]{0,1})\s*=\s*([^\]]*)\]|:([a-zA-Z-]+)\s*\(([^)]+)\){0,1}|:([a-zA-Z-]+)|::([a-zA-Z-]+)|([\s,+>]*)/gm;
/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_\\:]+)\]|\[([a-zA-Z0-9-_\\:]+)\s*([~|^$*]{0,1})\s*=\s*["']{1}([^"']*)["']{1}\s*(s|i){0,1}\]|\[([a-zA-Z0-9-_]+)\s*([~|^$*]{0,1})\s*=\s*([^\]]*)\]|:([a-zA-Z-]+)\s*\(([^)]+)\){0,1}|:([a-zA-Z-]+)|::([a-zA-Z-]+)|([\s,+>~]*)/gm;

/**
* Escaped Character RegExp.
Expand Down Expand Up @@ -193,6 +193,13 @@ export default class SelectorParser {
});
currentGroup.push(currentSelectorItem);
break;
case '~':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.subsequentSibling,
ignoreErrors
});
currentGroup.push(currentSelectorItem);
break;
case '':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.descendant,
Expand Down
114 changes: 114 additions & 0 deletions packages/happy-dom/test/query-selector/QuerySelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,45 @@ describe('QuerySelector', () => {
container.children[0]
]);
});

it('Returns all elements for subsequent sibling selector using ".a ~ .a"', () => {
const div = document.createElement('div');

div.innerHTML = `
<div class="a">a1</div>
<div class="b">b1</div>
<div class="c">c1</div>
<div class="a">a2</div>
<div class="b">b2</div>
<div class="a">a3</div>
`;

const subsequentSiblings = QuerySelector.querySelectorAll(div, '.a ~ .a');
expect(subsequentSiblings.length).toBe(2);
expect(subsequentSiblings[0].textContent).toBe('a2');
expect(subsequentSiblings[1].textContent).toBe('a3');

// Test cache 1
subsequentSiblings[0].className = 'z';

const subsequentSiblings2 = QuerySelector.querySelectorAll(div, '.a ~ .a');
expect(subsequentSiblings2.length).toBe(1);
expect(subsequentSiblings2[0].textContent).toBe('a3');

// Test cache 2
div.innerHTML = `
<div class="a">a1</div>
<div class="b">b1</div>
<div class="c">c1</div>
<div class="a">a2</div>
<div class="b">b2</div>
<div class="a">a3</div>
`;
const subsequentSiblings3 = QuerySelector.querySelectorAll(div, '.a ~ .a');
expect(subsequentSiblings3.length).toBe(2);
expect(subsequentSiblings3[0].textContent).toBe('a2');
expect(subsequentSiblings3[1].textContent).toBe('a3');
});
});

describe('querySelector()', () => {
Expand Down Expand Up @@ -1638,6 +1677,42 @@ describe('QuerySelector', () => {

expect(div.querySelector('.class1.class2')).toBe(div.children[0]);
});

it('Returns element matching subsequent sibling selector using ".a ~ .b"', () => {
const div = document.createElement('div');

div.innerHTML = `
<div class="a">a1</div>
<div class="b">b1</div>
<div class="c">c1</div>
<div class="a">a2</div>
<div class="b">b2</div>
<div class="a">a3</div>
`;

const sibling = <HTMLElement>div.querySelector('.a ~ .b');
const secondChild = div.children[1];

expect(secondChild === sibling).toBe(true);
expect(sibling.textContent).toBe('b1');

// Test cache 1
sibling.setAttribute('class', 'z');

expect(div.querySelector('.a ~ .b')).toBe(div.children[4]);

// Test cache 2
div.innerHTML = `
<div class="a">a1</div>
<div class="b">b1</div>
<div class="c">c1</div>
<div class="a">a2</div>
<div class="b">b2</div>
<div class="a">a3</div>
`;

expect(div.querySelector('.a ~ .b')).toBe(div.children[1]);
});
});

describe('matches()', () => {
Expand Down Expand Up @@ -1817,5 +1892,44 @@ describe('QuerySelector', () => {
expect(QuerySelector.matches(element, ':where', { ignoreErrors: true })).toBe(null);
expect(QuerySelector.matches(element, 'div:not', { ignoreErrors: true })).toBe(null);
});

it('Returns element matching subsequent sibling selector using ".a ~ .b"', () => {
const div = document.createElement('div');

div.innerHTML = `
<div class="a">a1</div>
<div class="b">b1</div>
<div class="c">c1</div>
<div class="a">a2</div>
<div class="b">b2</div>
<div class="a">a3</div>
`;

const sibling = <HTMLElement>div.querySelector('.a ~ .b');

expect(sibling.matches('.a ~ .b')).toBe(true);
expect(sibling.matches('.a ~ .z')).toBe(false);

// Test cache 1
sibling.setAttribute('class', 'z');

expect(sibling.matches('.a ~ .b')).toBe(false);
expect(sibling.matches('.a ~ .z')).toBe(true);

// Test cache 2
div.innerHTML = `
<div class="a">a1</div>
<div class="b">b1</div>
<div class="c">c1</div>
<div class="a">a2</div>
<div class="b">b2</div>
<div class="a">a3</div>
`;

const sibling2 = <HTMLElement>div.querySelector('.a ~ .b');

expect(sibling2.matches('.a ~ .b')).toBe(true);
expect(sibling2.matches('.a ~ .z')).toBe(false);
});
});
});

0 comments on commit 3bb23c2

Please sign in to comment.