Repository: capricorn86/happy-dom
Pull request #906 (2023-05-11 15:33:13)

#905@minor: Adds support for "nth-child of" query selector. Fixes but…
… where nth-child did not return the correct elements when "-" was prefixed to "n". Significally improves performance of the nth query selectors.


Patch:
diff --git a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts
index 462ff7ce..f712c745 100644
--- a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts
+++ b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts
@@ -1,4 +1,8 @@
+import SelectorItem from './SelectorItem';
+
 export default interface ISelectorPseudo {
 	name: string;
 	arguments: string | null;
+	selectorItem: SelectorItem | null;
+	nthFunction: ((n: number) => boolean) | null;
 }
diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts
index 673e4c57..361295d3 100644
--- a/packages/happy-dom/src/query-selector/SelectorItem.ts
+++ b/packages/happy-dom/src/query-selector/SelectorItem.ts
@@ -4,7 +4,6 @@ import Element from '../nodes/element/Element';
 import IHTMLInputElement from '../nodes/html-input-element/IHTMLInputElement';
 import SelectorCombinatorEnum from './SelectorCombinatorEnum';
 import ISelectorAttribute from './ISelectorAttribute';
-import SelectorParser from './SelectorParser';
 import ISelectorMatch from './ISelectorMatch';
 import ISelectorPseudo from './ISelectorPseudo';
 
@@ -181,74 +180,40 @@ export default class SelectorItem {
 				case 'root':
 					return element.tagName === 'HTML';
 				case 'not':
-					return !SelectorParser.getSelectorItem(psuedo.arguments).match(element);
+					return !psuedo.selectorItem.match(element);
 				case 'nth-child':
-					return this.matchNthChild(element, parent.children, psuedo.arguments);
+					const nthChildIndex = psuedo.selectorItem
+						? parent.children.filter((child) => psuedo.selectorItem.match(child)).indexOf(element)
+						: parent.children.indexOf(element);
+					return nthChildIndex !== -1 && psuedo.nthFunction(nthChildIndex + 1);
 				case 'nth-of-type':
 					if (!element.parentNode) {
 						return false;
 					}
-					return this.matchNthChild(
-						element,
-						parent.children.filter((child) => child.tagName === element.tagName),
-						psuedo.arguments
-					);
+					const nthOfTypeIndex = parent.children
+						.filter((child) => child.tagName === element.tagName)
+						.indexOf(element);
+					return nthOfTypeIndex !== -1 && psuedo.nthFunction(nthOfTypeIndex + 1);
 				case 'nth-last-child':
-					return this.matchNthChild(element, parent.children.reverse(), psuedo.arguments);
+					const nthLastChildIndex = psuedo.selectorItem
+						? parent.children
+								.filter((child) => psuedo.selectorItem.match(child))
+								.reverse()
+								.indexOf(element)
+						: parent.children.reverse().indexOf(element);
+					return nthLastChildIndex !== -1 && psuedo.nthFunction(nthLastChildIndex + 1);
 				case 'nth-last-of-type':
-					return this.matchNthChild(
-						element,
-						parent.children.filter((child) => child.tagName === element.tagName).reverse(),
-						psuedo.arguments
-					);
+					const nthLastOfTypeIndex = parent.children
+						.filter((child) => child.tagName === element.tagName)
+						.reverse()
+						.indexOf(element);
+					return nthLastOfTypeIndex !== -1 && psuedo.nthFunction(nthLastOfTypeIndex + 1);
 			}
 		}
 
 		return true;
 	}
 
-	/**
-	 * Matches a nth-child selector.
-	 *
-	 * @param element Element.
-	 * @param parentChildren Parent children.
-	 * @param placement Placement.
-	 * @returns True if it is a match.
-	 */
-	private matchNthChild(element: IElement, parentChildren: IElement[], placement: string): boolean {
-		if (placement === 'odd') {
-			const index = parentChildren.indexOf(element);
-			return index !== -1 && (index + 1) % 2 !== 0;
-		} else if (placement === 'even') {
-			const index = parentChildren.indexOf(element);
-			return index !== -1 && (index + 1) % 2 === 0;
-		} else if (placement.includes('n')) {
-			const [a, b] = placement.replace(/ /g, '').split('n');
-			const childIndex = parentChildren.indexOf(element);
-			const aNumber = a !== '' ? Number(a) : 1;
-			const bNumber = b !== undefined ? Number(b) : 0;
-			if (isNaN(aNumber) || isNaN(bNumber)) {
-				throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`);
-			}
-
-			for (let i = 0, max = parentChildren.length; i <= max; i += aNumber) {
-				if (childIndex === i + bNumber - 1) {
-					return true;
-				}
-			}
-
-			return false;
-		}
-
-		const number = Number(placement);
-
-		if (isNaN(number)) {
-			throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`);
-		}
-
-		return parentChildren[number - 1] === element;
-	}
-
 	/**
 	 * Matches attribute.
 	 *
diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts
index ca1843fd..5d1715cf 100644
--- a/packages/happy-dom/src/query-selector/SelectorParser.ts
+++ b/packages/happy-dom/src/query-selector/SelectorParser.ts
@@ -1,6 +1,7 @@
 import SelectorItem from './SelectorItem';
 import SelectorCombinatorEnum from './SelectorCombinatorEnum';
 import DOMException from '../exception/DOMException';
+import ISelectorPseudo from './ISelectorPseudo';
 
 /**
  * Selector RegExp.
@@ -29,6 +30,20 @@ const SELECTOR_REGEXP =
  */
 const CLASS_ESCAPED_CHARACTER_REGEXP = /\\/g;
 
+/**
+ * Nth Function.
+ */
+const NTH_FUNCTION = {
+	odd: (n: number) => (n + 1) % 2 === 0,
+	even: (n: number) => (n + 1) % 2 !== 0,
+	alwaysFalse: () => false
+};
+
+/**
+ * Space RegExp.
+ */
+const SPACE_REGEXP = / /g;
+
 /**
  * Simple Selector RegExp.
  *
@@ -120,16 +135,10 @@ export default class SelectorParser {
 					});
 				} else if (match[12] && match[13]) {
 					currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
-					currentSelectorItem.pseudos.push({
-						name: match[12].toLowerCase(),
-						arguments: match[13]
-					});
+					currentSelectorItem.pseudos.push(this.getPseudo(match[12], match[13]));
 				} else if (match[14]) {
 					currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
-					currentSelectorItem.pseudos.push({
-						name: match[14].toLowerCase(),
-						arguments: null
-					});
+					currentSelectorItem.pseudos.push(this.getPseudo(match[14]));
 				} else if (match[15]) {
 					switch (match[15].trim()) {
 						case ',':
@@ -168,4 +177,105 @@ export default class SelectorParser {
 
 		return groups;
 	}
+
+	/**
+	 * Returns pseudo.
+	 *
+	 * @param name Pseudo name.
+	 * @param args Pseudo arguments.
+	 * @returns Pseudo.
+	 */
+	private static getPseudo(name: string, args?: string): ISelectorPseudo {
+		const lowerName = name.toLowerCase();
+
+		if (!args) {
+			return { name: lowerName, arguments: null, selectorItem: null, nthFunction: null };
+		}
+
+		switch (lowerName) {
+			case 'nth-last-child':
+			case 'nth-child':
+				const nthOfIndex = args.indexOf(' of ');
+				const nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args;
+				const selectorItem =
+					nthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null;
+				return {
+					name: lowerName,
+					arguments: args,
+					selectorItem,
+					nthFunction: this.getPseudoNthFunction(nthFunction)
+				};
+			case 'nth-of-type':
+			case 'nth-last-of-type':
+				return {
+					name: lowerName,
+					arguments: args,
+					selectorItem: null,
+					nthFunction: this.getPseudoNthFunction(args)
+				};
+			case 'not':
+				return {
+					name: lowerName,
+					arguments: args,
+					selectorItem: this.getSelectorItem(args),
+					nthFunction: null
+				};
+			default:
+				return { name: lowerName, arguments: args, selectorItem: null, nthFunction: null };
+		}
+	}
+
+	/**
+	 * Returns pseudo nth function.
+	 *
+	 * Based on:
+	 * https://github.com/dperini/nwsapi/blob/master/src/nwsapi.js
+	 *
+	 * @param args Pseudo arguments.
+	 * @returns Pseudo nth function.
+	 */
+	private static getPseudoNthFunction(args?: string): ((n: number) => boolean) | null {
+		if (args === 'odd') {
+			return NTH_FUNCTION.odd;
+		} else if (args === 'even') {
+			return NTH_FUNCTION.even;
+		}
+
+		const parts = args.replace(SPACE_REGEXP, '').split('n');
+		let partA = parseInt(parts[0], 10) || 0;
+
+		if (parts[0] == '-') {
+			partA = -1;
+		}
+
+		if (parts.length === 1) {
+			return (n) => n == partA;
+		}
+
+		let partB = parseInt(parts[1], 10) || 0;
+
+		if (parts[0] == '+') {
+			partB = 1;
+		}
+
+		if (partA >= 1 || partA <= -1) {
+			const innerExpression =
+				(partB ? '(n' + (partB > 0 ? '-' : '+') + Math.abs(partB) + ')' : 'n') +
+				'%' +
+				partA +
+				'==0';
+			if (partA >= 1) {
+				return eval(
+					'(n) => n>' + (partB - 1) + (Math.abs(partA) != 1 ? '&&' + innerExpression : '')
+				);
+			}
+			return eval('(n) => n<' + (partB + 1) + (Math.abs(partA) != 1 ? '&&' + innerExpression : ''));
+		}
+
+		if (parts[0]) {
+			return (n) => n === partB;
+		}
+
+		return (n) => n > partB - 1;
+	}
 }


Test patch:
diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts
index ff7d4524..042743a7 100644
--- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts
+++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts
@@ -730,6 +730,18 @@ describe('QuerySelector', () => {
 			).toEqual(['span.n2', 'b.n4', 'div.n6', 'span.n8', 'i.n10']);
 		});
 
+		it('Returns all elements matching :nth-child(-n + 3).', () => {
+			const container = document.createElement('div');
+			container.innerHTML = QuerySelectorNthChildHTML;
+			const elements = container.querySelectorAll(':nth-child(-n + 3)');
+
+			expect(
+				Array.from(
+					elements.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
+				)
+			).toEqual(['div.', 'b.n1', 'span.n2', 'div.n3']);
+		});
+
 		it('Returns all elements matching "div :nth-child(2n+1)".', () => {
 			const container = document.createElement('div');
 			container.innerHTML = QuerySelectorNthChildHTML;
@@ -754,6 +766,42 @@ describe('QuerySelector', () => {
 			).toEqual(['div.', 'b.n1', 'b.n4', 'b.n7', 'i.n10']);
 		});
 
+		it('Returns all elements matching ":nth-child(3n+1 of b)".', () => {
+			const container = document.createElement('div');
+			container.innerHTML = QuerySelectorNthChildHTML;
+			const elements = container.querySelectorAll(':nth-child(3n+1 of b)');
+
+			expect(
+				Array.from(
+					elements.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
+				)
+			).toEqual(['b.n1']);
+		});
+
+		it('Returns all elements matching ":nth-child(n+1 of span)".', () => {
+			const container = document.createElement('div');
+			container.innerHTML = QuerySelectorNthChildHTML;
+			const elements = container.querySelectorAll(':nth-child(n+1 of span)');
+
+			expect(
+				Array.from(
+					elements.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
+				)
+			).toEqual(['span.n2', 'span.n5', 'span.n8']);
+		});
+
+		it('Returns all elements matching ":nth-last-child(n+1 of span)".', () => {
+			const container = document.createElement('div');
+			container.innerHTML = QuerySelectorNthChildHTML;
+			const elements = container.querySelectorAll(':nth-last-child(n+1 of span)');
+
+			expect(
+				Array.from(
+					elements.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
+				)
+			).toEqual(['span.n2', 'span.n5', 'span.n8']);
+		});
+
 		it('Returns all elements matching "div :nth-child(3n+3)".', () => {
 			const container = document.createElement('div');
 			container.innerHTML = QuerySelectorNthChildHTML;