Skip to content

Commit

Permalink
feat: [#1373] Adds support for the :has pseudo selector (#1521)
Browse files Browse the repository at this point in the history
  • Loading branch information
capricorn86 authored Aug 30, 2024
1 parent 2c52052 commit 9bde659
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 6 deletions.
53 changes: 49 additions & 4 deletions packages/happy-dom/src/query-selector/SelectorItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,21 +308,43 @@ 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)) {
return { priorityWeight: 0 };
}
}
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
Expand Down Expand Up @@ -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.
*
Expand Down
22 changes: 20 additions & 2 deletions packages/happy-dom/src/query-selector/SelectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 };
}
Expand Down
55 changes: 55 additions & 0 deletions packages/happy-dom/test/query-selector/QuerySelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<span><video attr="value1"></video></span>
<span><b><video></video></b></span>
<video></video>
<h1></h1>
<h2></h2>
`;
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', () => {
Expand Down Expand Up @@ -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 = `
<span><video attr="value1"></video></span>
<span><b><video></video></b></span>
<video></video>
<h1></h1>
<h2></h2>
`;
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');

Expand Down Expand Up @@ -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 = `
<span><video attr="value1"></video></span>
<span><b><video></video></b></span>
<video></video>
<h1></h1>
<h2></h2>
`;
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 = <HTMLElement>document.querySelector('span.class1');
Expand Down

0 comments on commit 9bde659

Please sign in to comment.