diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 9bfb1b52f..744fe262a 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -308,14 +308,14 @@ export default class SelectorItem { } return element.isConnected && element.id === hash.slice(1) ? { priorityWeight: 10 } : null; case 'is': - let priorityWeight = 0; + let priorityWeightForIs = 0; for (const selectorItem of pseudo.selectorItems) { const match = selectorItem.match(element); - if (match) { - priorityWeight = match.priorityWeight; + if (match && priorityWeightForIs < match.priorityWeight) { + priorityWeightForIs = match.priorityWeight; } } - return priorityWeight ? { priorityWeight } : null; + return priorityWeightForIs ? { priorityWeight: priorityWeightForIs } : null; case 'where': for (const selectorItem of pseudo.selectorItems) { if (selectorItem.match(element)) { @@ -323,6 +323,28 @@ export default class SelectorItem { } } return null; + case 'has': + let priorityWeightForHas = 0; + if (pseudo.arguments.startsWith('+')) { + const nextSibling = element.nextElementSibling; + if (!nextSibling) { + return null; + } + for (const selectorItem of pseudo.selectorItems) { + const match = selectorItem.match(nextSibling); + if (match && priorityWeightForHas < match.priorityWeight) { + priorityWeightForHas = match.priorityWeight; + } + } + } else { + for (const selectorItem of pseudo.selectorItems) { + const match = this.matchChildOfElement(selectorItem, element); + if (match && priorityWeightForHas < match.priorityWeight) { + priorityWeightForHas = match.priorityWeight; + } + } + } + return priorityWeightForHas ? { priorityWeight: priorityWeightForHas } : null; case 'focus': case 'focus-visible': return element[PropertySymbol.ownerDocument].activeElement === element @@ -394,6 +416,29 @@ export default class SelectorItem { return { priorityWeight }; } + /** + * Matches a selector item against children of an element. + * + * @param selectorItem Selector item. + * @param element Element. + * @returns Result. + */ + private matchChildOfElement( + selectorItem: SelectorItem, + element: Element + ): { priorityWeight: number } | null { + for (const child of element[PropertySymbol.elementArray]) { + const match = selectorItem.match(child); + if (match) { + return match; + } + const childMatch = this.matchChildOfElement(selectorItem, child); + if (childMatch) { + return childMatch; + } + } + } + /** * Returns the selector string. * diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts index 0569a88d2..da6875b08 100644 --- a/packages/happy-dom/src/query-selector/SelectorParser.ts +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts @@ -313,9 +313,8 @@ export default class SelectorParser { }; case 'is': case 'where': - const selectorGroups = this.getSelectorGroups(args, options); const selectorItems = []; - for (const group of selectorGroups) { + for (const group of this.getSelectorGroups(args, options)) { selectorItems.push(group[0]); } return { @@ -324,6 +323,25 @@ export default class SelectorParser { selectorItems, nthFunction: null }; + case 'has': + const hasSelectorItems = []; + + // The ":has()" pseudo selector doesn't allow for it to be nested inside another ":has()" pseudo selector, as it can lead to cyclic querying. + if (!args.includes(':has(')) { + for (const group of this.getSelectorGroups( + args.startsWith('+') ? args.replace('+', '') : args, + options + )) { + hasSelectorItems.push(group[0]); + } + } + + return { + name: lowerName, + arguments: args, + selectorItems: hasSelectorItems, + nthFunction: null + }; default: return { name: lowerName, arguments: args, selectorItems: null, nthFunction: null }; } diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts index 95b57a554..9716d67a2 100644 --- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts +++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts @@ -1087,6 +1087,30 @@ describe('QuerySelector', () => { expect(document.querySelectorAll(':focus')[0]).toBe(div); expect(document.querySelectorAll(':focus-visible')[0]).toBe(div); }); + + it('Returns element matching selector with CSS pseudo ":has()"', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + +

+

+ `; + expect(Array.from(container.querySelectorAll('span:has(video)'))).toEqual([ + container.children[0], + container.children[1] + ]); + expect(Array.from(container.querySelectorAll('span:has(video[attr="value1"])'))).toEqual([ + container.children[0] + ]); + expect(Array.from(container.querySelectorAll('span:has(+video)'))).toEqual([ + container.children[1] + ]); + expect(Array.from(container.querySelectorAll('h1:has(+h2)'))).toEqual([ + container.children[3] + ]); + }); }); describe('querySelector', () => { @@ -1399,6 +1423,21 @@ describe('QuerySelector', () => { expect(container.querySelector(':where(span[attr1="val,ue1"])')).toBe(null); }); + it('Returns element matching selector with CSS pseudo ":has()"', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + +

+

+ `; + expect(container.querySelector('span:has(video)')).toBe(container.children[0]); + expect(container.querySelector('span:has(video[attr="value1"])')).toBe(container.children[0]); + expect(container.querySelector('span:has(+video)')).toBe(container.children[1]); + expect(container.querySelector('h1:has(+h2)')).toBe(container.children[3]); + }); + it('Remove new line from selector and trim selector before parse', () => { const container = document.createElement('div'); @@ -1540,6 +1579,22 @@ describe('QuerySelector', () => { expect(element.matches(':where(div)')).toBe(false); }); + it('Returns element matching selector with CSS pseudo ":has()"', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + +

+

+ `; + expect(container.children[0].matches('span:has(video)')).toBe(true); + expect(container.children[0].matches(':has(video[attr="value1"])')).toBe(true); + expect(container.children[1].matches('span:has(+video)')).toBe(true); + expect(container.children[3].matches(':has(+h2)')).toBe(true); + expect(container.children[3].matches('h1:has(+h2)')).toBe(true); + }); + it('Returns true for selector with CSS pseudo ":focus" and ":focus-visible"', () => { document.body.innerHTML = QuerySelectorHTML; const span = document.querySelector('span.class1');