Skip to content

Commit

Permalink
Merge pull request #1341 from capricorn86/1333-regression-in-is-pseud…
Browse files Browse the repository at this point in the history
…o-class-function-1

feat: [#1333] Adds support for the pseudo selectors :is() and :where()
  • Loading branch information
capricorn86 committed Mar 20, 2024
2 parents ad3234d + c871b44 commit f42adfa
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 61 deletions.
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);
});
});
});

0 comments on commit f42adfa

Please sign in to comment.