Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [#1373] Adds support for the :has pseudo selector #1521

Merged
merged 1 commit into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading