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: [#1333] Adds support for the pseudo selectors :is() and :where() #1341

Merged
merged 1 commit into from
Mar 20, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/happy-dom/src/query-selector/ISelectorPseudo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import SelectorItem from './SelectorItem.js';
export default interface ISelectorPseudo {
name: string;
arguments: string | null;
selectorItem: SelectorItem | null;
selectorItems: SelectorItem[] | null;
nthFunction: ((n: number) => boolean) | null;
}
108 changes: 74 additions & 34 deletions packages/happy-dom/src/query-selector/SelectorItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ export default class SelectorItem {
}

// Pseudo match
if (this.pseudos && !this.matchPsuedo(element)) {
return null;
if (this.pseudos) {
const result = this.matchPsuedo(element);
if (!result) {
return null;
}
priorityWeight += result.priorityWeight;
}

return { priorityWeight };
Expand All @@ -110,16 +114,18 @@ export default class SelectorItem {
* @param element Element.
* @returns Result.
*/
private matchPsuedo(element: Element): boolean {
private matchPsuedo(element: Element): ISelectorMatch | null {
const parent = <Element>element[PropertySymbol.parentNode];
const parentChildren = element[PropertySymbol.parentNode]
? (<Element>element[PropertySymbol.parentNode])[PropertySymbol.children]
: [];

if (!this.pseudos) {
return true;
return { priorityWeight: 0 };
}

let priorityWeight = 0;

for (const pseudo of this.pseudos) {
// Validation
switch (pseudo.name) {
Expand Down Expand Up @@ -147,16 +153,20 @@ export default class SelectorItem {
case 'nth-of-type':
case 'nth-last-child':
case 'nth-last-of-type':
return false;
return null;
}
}

if (!this.matchPseudoItem(element, parentChildren, pseudo)) {
return false;
const selectorMatch = this.matchPseudoItem(element, parentChildren, pseudo);

if (!selectorMatch) {
return null;
}

priorityWeight += selectorMatch.priorityWeight;
}

return true;
return { priorityWeight };
}

/**
Expand All @@ -170,83 +180,113 @@ export default class SelectorItem {
element: Element,
parentChildren: Element[],
pseudo: ISelectorPseudo
): boolean {
): ISelectorMatch | null {
switch (pseudo.name) {
case 'first-child':
return parentChildren[0] === element;
return parentChildren[0] === element ? { priorityWeight: 10 } : null;
case 'last-child':
return parentChildren.length && parentChildren[parentChildren.length - 1] === element;
return parentChildren.length && parentChildren[parentChildren.length - 1] === element
? { priorityWeight: 10 }
: null;
case 'only-child':
return parentChildren.length === 1 && parentChildren[0] === element;
return parentChildren.length === 1 && parentChildren[0] === element
? { priorityWeight: 10 }
: null;
case 'first-of-type':
for (const child of parentChildren) {
if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) {
return child === element;
return child === element ? { priorityWeight: 10 } : null;
}
}
return false;
return null;
case 'last-of-type':
for (let i = parentChildren.length - 1; i >= 0; i--) {
const child = parentChildren[i];
if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) {
return child === element;
return child === element ? { priorityWeight: 10 } : null;
}
}
return false;
return null;
case 'only-of-type':
let isFound = false;
for (const child of parentChildren) {
if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) {
if (isFound || child !== element) {
return false;
return null;
}
isFound = true;
}
}
return isFound;
return isFound ? { priorityWeight: 10 } : null;
case 'checked':
return element[PropertySymbol.tagName] === 'INPUT' && (<HTMLInputElement>element).checked;
return element[PropertySymbol.tagName] === 'INPUT' && (<HTMLInputElement>element).checked
? { priorityWeight: 10 }
: null;
case 'empty':
return !(<Element>element)[PropertySymbol.children].length;
return !(<Element>element)[PropertySymbol.children].length ? { priorityWeight: 10 } : null;
case 'root':
return element[PropertySymbol.tagName] === 'HTML';
return element[PropertySymbol.tagName] === 'HTML' ? { priorityWeight: 10 } : null;
case 'not':
return !pseudo.selectorItem.match(element);
return !pseudo.selectorItems[0].match(element) ? { priorityWeight: 10 } : null;
case 'nth-child':
const nthChildIndex = pseudo.selectorItem
? parentChildren.filter((child) => pseudo.selectorItem.match(child)).indexOf(element)
const nthChildIndex = pseudo.selectorItems[0]
? parentChildren.filter((child) => pseudo.selectorItems[0].match(child)).indexOf(element)
: parentChildren.indexOf(element);
return nthChildIndex !== -1 && pseudo.nthFunction(nthChildIndex + 1);
return nthChildIndex !== -1 && pseudo.nthFunction(nthChildIndex + 1)
? { priorityWeight: 10 }
: null;
case 'nth-of-type':
if (!element[PropertySymbol.parentNode]) {
return false;
return null;
}
const nthOfTypeIndex = parentChildren
.filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName])
.indexOf(element);
return nthOfTypeIndex !== -1 && pseudo.nthFunction(nthOfTypeIndex + 1);
return nthOfTypeIndex !== -1 && pseudo.nthFunction(nthOfTypeIndex + 1)
? { priorityWeight: 10 }
: null;
case 'nth-last-child':
const nthLastChildIndex = pseudo.selectorItem
const nthLastChildIndex = pseudo.selectorItems[0]
? parentChildren
.filter((child) => pseudo.selectorItem.match(child))
.filter((child) => pseudo.selectorItems[0].match(child))
.reverse()
.indexOf(element)
: parentChildren.reverse().indexOf(element);
return nthLastChildIndex !== -1 && pseudo.nthFunction(nthLastChildIndex + 1);
return nthLastChildIndex !== -1 && pseudo.nthFunction(nthLastChildIndex + 1)
? { priorityWeight: 10 }
: null;
case 'nth-last-of-type':
const nthLastOfTypeIndex = parentChildren
.filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName])
.reverse()
.indexOf(element);
return nthLastOfTypeIndex !== -1 && pseudo.nthFunction(nthLastOfTypeIndex + 1);
return nthLastOfTypeIndex !== -1 && pseudo.nthFunction(nthLastOfTypeIndex + 1)
? { priorityWeight: 10 }
: null;
case 'target':
const hash = element[PropertySymbol.ownerDocument].location.hash;
if (!hash) {
return false;
return null;
}
return element.isConnected && element.id === hash.slice(1) ? { priorityWeight: 10 } : null;
case 'is':
let priorityWeight = 0;
for (const selectorItem of pseudo.selectorItems) {
const match = selectorItem.match(element);
if (match) {
priorityWeight = match.priorityWeight;
}
}
return element.isConnected && element.id === hash.slice(1);
return priorityWeight ? { priorityWeight } : null;
case 'where':
for (const selectorItem of pseudo.selectorItems) {
if (selectorItem.match(element)) {
return { priorityWeight: 0 };
}
}
return null;
default:
return false;
return null;
}
}

Expand Down
23 changes: 18 additions & 5 deletions packages/happy-dom/src/query-selector/SelectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export default class SelectorParser {
const lowerName = name.toLowerCase();

if (!args) {
return { name: lowerName, arguments: null, selectorItem: null, nthFunction: null };
return { name: lowerName, arguments: null, selectorItems: null, nthFunction: null };
}

switch (lowerName) {
Expand All @@ -260,26 +260,39 @@ export default class SelectorParser {
return {
name: lowerName,
arguments: args,
selectorItem,
selectorItems: [selectorItem],
nthFunction: this.getPseudoNthFunction(nthFunction)
};
case 'nth-of-type':
case 'nth-last-of-type':
return {
name: lowerName,
arguments: args,
selectorItem: null,
selectorItems: null,
nthFunction: this.getPseudoNthFunction(args)
};
case 'not':
return {
name: lowerName,
arguments: args,
selectorItem: this.getSelectorItem(args),
selectorItems: [this.getSelectorItem(args)],
nthFunction: null
};
case 'is':
case 'where':
const selectorGroups = this.getSelectorGroups(args);
const selectorItems = [];
for (const group of selectorGroups) {
selectorItems.push(group[0]);
}
return {
name: lowerName,
arguments: args,
selectorItems,
nthFunction: null
};
default:
return { name: lowerName, arguments: args, selectorItem: null, nthFunction: null };
return { name: lowerName, arguments: args, selectorItems: null, nthFunction: null };
}
}

Expand Down
60 changes: 58 additions & 2 deletions packages/happy-dom/test/query-selector/QuerySelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,7 @@ describe('QuerySelector', () => {
expect(div.querySelector(':not(:nth-child(1))')).toBe(child2);
});

it('Returns false for selector with CSS pseado element ":before".', () => {
it('Returns null for selector with CSS pseado element ":before".', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorHTML;
expect(
Expand All @@ -1251,7 +1251,7 @@ describe('QuerySelector', () => {
expect(container.querySelector('span.class1:first-of-type:before') === null).toBe(true);
});

it('Returns false for selector with CSS pseado element ":after".', () => {
it('Returns null for selector with CSS pseado element ":after".', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorHTML;
expect(
Expand All @@ -1264,6 +1264,38 @@ describe('QuerySelector', () => {
expect(container.querySelector('span.class1:after') === null).toBe(true);
expect(container.querySelector('span.class1:first-of-type:after') === null).toBe(true);
});

it('Returns element matching selector with CSS pseudo ":is()"', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorHTML;
expect(container.querySelector(':is(span[attr1="word1.word2"])')).toBe(
container.children[0].children[1].children[2]
);
expect(container.querySelector(':is(div, span[attr1="word1.word2"])')).toBe(
container.children[0]
);
expect(container.querySelector(':is(span[attr1="val,ue1"], span[attr1="value1"])')).toBe(
container.children[0].children[1].children[0]
);
expect(container.querySelector(':is(div)')).toBe(container.children[0]);
expect(container.querySelector(':is(span[attr1="val,ue1"])')).toBe(null);
});

it('Returns element matching selector with CSS pseudo ":where()"', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorHTML;
expect(container.querySelector(':where(span[attr1="word1.word2"])')).toBe(
container.children[0].children[1].children[2]
);
expect(container.querySelector(':where(div, span[attr1="word1.word2"])')).toBe(
container.children[0]
);
expect(container.querySelector(':where(span[attr1="val,ue1"], span[attr1="value1"])')).toBe(
container.children[0].children[1].children[0]
);
expect(container.querySelector(':where(div)')).toBe(container.children[0]);
expect(container.querySelector(':where(span[attr1="val,ue1"])')).toBe(null);
});
});

describe('match()', () => {
Expand Down Expand Up @@ -1308,5 +1340,29 @@ describe('QuerySelector', () => {
expect(element.matches('span.class1:after')).toBe(false);
expect(element.matches('span.class1:first-of-type:after')).toBe(false);
});

it('Returns true for selector with CSS pseudo ":is()"', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorHTML;
const element = container.children[0].children[1].children[0];
expect(element.matches(':is(span)')).toBe(true);
expect(element.matches(':is(div, span)')).toBe(true);
expect(element.matches(':is(div, span.class1)')).toBe(true);
expect(element.matches(':is(div, span[attr1="value1"])')).toBe(true);
expect(element.matches(':is(span[attr1="val,ue1"], span[attr1="value1"])')).toBe(true);
expect(element.matches(':is(div)')).toBe(false);
});

it('Returns true for selector with CSS pseudo ":where()"', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorHTML;
const element = container.children[0].children[1].children[0];
expect(element.matches(':where(span)')).toBe(true);
expect(element.matches(':where(div, span)')).toBe(true);
expect(element.matches(':where(div, span.class1)')).toBe(true);
expect(element.matches(':where(div, span[attr1="value1"])')).toBe(true);
expect(element.matches(':where(span[attr1="val,ue1"], span[attr1="value1"])')).toBe(true);
expect(element.matches(':where(div)')).toBe(false);
});
});
});