Repository: capricorn86/happy-dom
Pull request #386 (2022-03-02 14:08:28)

#383@patch: Fixes problem with using classes in ":not" pseudo CSS sel…
…ectors.


Patch:
diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts
index b9a83b69..a1b57d71 100644
--- a/packages/happy-dom/src/query-selector/SelectorItem.ts
+++ b/packages/happy-dom/src/query-selector/SelectorItem.ts
@@ -27,10 +27,11 @@ export default class SelectorItem {
 	 * @param selector Selector.
 	 */
 	constructor(selector: string) {
+		const isNotPseudo = selector.includes(':not(');
+
 		this.isAll = selector === '*';
 		this.isID = !this.isAll ? selector.startsWith('#') : false;
-		this.isAttribute =
-			!this.isAll && !this.isID && selector.includes('[') && !selector.includes(':not(');
+		this.isAttribute = !this.isAll && !this.isID && selector.includes('[') && !isNotPseudo;
 		this.isPseudo = !this.isAll && !this.isID && selector.includes(':');
 		this.isClass = !this.isAll && !this.isID && new RegExp(CLASS_REGEXP, 'g').test(selector);
 		this.tagName = !this.isAll && !this.isID ? selector.match(TAG_NAME_REGEXP) : null;
@@ -48,7 +49,6 @@ export default class SelectorItem {
 	 */
 	public match(element: Element): boolean {
 		const selector = this.selector;
-		let match;
 
 		// Is all (*)
 		if (this.isAll) {
@@ -68,29 +68,13 @@ export default class SelectorItem {
 		}
 
 		// Class match
-		if (this.isClass) {
-			const regexp = new RegExp(CLASS_REGEXP, 'g');
-
-			while ((match = regexp.exec(selector))) {
-				if (!element.classList.contains(match[1])) {
-					return false;
-				}
-			}
+		if (this.isClass && !this.matchesClass(element, selector)) {
+			return false;
 		}
 
 		// Pseudo match
-		if (this.isPseudo) {
-			const regexp = new RegExp(PSUEDO_REGEXP, 'g');
-
-			while ((match = regexp.exec(selector))) {
-				if (match[1] && !this.matchesNthChild(element, match[1], match[2])) {
-					return false;
-				} else if (match[3] && this.matchesAttribute(element, match[3])) {
-					return false;
-				} else if (match[4] && !this.matchesPsuedo(element, match[4])) {
-					return false;
-				}
-			}
+		if (this.isPseudo && !this.matchesPsuedo(element, selector)) {
+			return false;
 		}
 
 		// Attribute match
@@ -101,6 +85,35 @@ export default class SelectorItem {
 		return true;
 	}
 
+	/**
+	 * Matches a psuedo selector.
+	 *
+	 * @param element Element.
+	 * @param selector Selector.
+	 * @returns True if it is a match.
+	 */
+	private matchesPsuedo(element: Element, selector: string): boolean {
+		const regexp = new RegExp(PSUEDO_REGEXP, 'g');
+		let match: RegExpMatchArray;
+
+		while ((match = regexp.exec(selector))) {
+			const isNotClass = match[3] && match[3].trim()[0] === '.';
+			if (match[1] && !this.matchesNthChild(element, match[1], match[2])) {
+				return false;
+			} else if (
+				match[3] &&
+				((isNotClass && this.matchesClass(element, match[3])) ||
+					(!isNotClass && this.matchesAttribute(element, match[3])))
+			) {
+				return false;
+			} else if (match[4] && !this.matchesPsuedoExpression(element, match[4])) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * Matches a nth-child selector.
 	 *
@@ -158,13 +171,13 @@ export default class SelectorItem {
 	}
 
 	/**
-	 * Matches a psuedo selector.
+	 * Matches a psuedo selector expression.
 	 *
 	 * @param element Element.
 	 * @param psuedo Psuedo name.
 	 * @returns True if it is a match.
 	 */
-	private matchesPsuedo(element: Element, psuedo: string): boolean {
+	private matchesPsuedoExpression(element: Element, psuedo: string): boolean {
 		const parent = <Element>element.parentNode;
 
 		if (!parent) {
@@ -220,7 +233,7 @@ export default class SelectorItem {
 	 */
 	private matchesAttribute(element: Element, selector: string): boolean {
 		const regexp = new RegExp(ATTRIBUTE_REGEXP, 'g');
-		let match;
+		let match: RegExpMatchArray;
 
 		while ((match = regexp.exec(selector))) {
 			const isPsuedo = match.index > 0 && selector[match.index] === '(';
@@ -236,6 +249,29 @@ export default class SelectorItem {
 		return true;
 	}
 
+	/**
+	 * Matches class.
+	 *
+	 * @param element Element.
+	 * @param selector Selector.
+	 * @returns True if it is a match.
+	 */
+	private matchesClass(element: Element, selector: string): boolean {
+		const regexp = new RegExp(CLASS_REGEXP, 'g');
+		const classList = element.className.split(' ');
+		let previousIndex = 0;
+		let match: RegExpMatchArray;
+
+		while ((match = regexp.exec(selector)) && match.index === previousIndex) {
+			if (!classList.includes(match[1])) {
+				return false;
+			}
+			previousIndex = match.index + match[0].length - 1;
+		}
+
+		return true;
+	}
+
 	/**
 	 * Matches attribute name only.
 	 *


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 65b01ba2..387f0132 100644
--- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts
+++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts
@@ -359,6 +359,45 @@ describe('QuerySelector', () => {
 			expect(elements[0]).toBe(container.children[0].children[1].children[1]);
 		});
 
+		it('Returns all elements matching ".foo:not(.bar)".', () => {
+			const container = document.createElement('div');
+			container.innerHTML = `
+				<div data-foo data-bar class="foo bar"></div>
+				<div data-foo class="foo"></div>
+				<div data-bar class="bar"></div>
+			`;
+			const elements = container.querySelectorAll('.foo:not(.bar)');
+
+			expect(elements.length).toBe(1);
+			expect(elements[0]).toBe(container.children[1]);
+		});
+
+		it('Returns all elements matching ".bar:not(.foo)".', () => {
+			const container = document.createElement('div');
+			container.innerHTML = `
+				<div data-foo data-bar class="foo bar"></div>
+				<div data-foo class="foo"></div>
+				<div data-bar class="bar"></div>
+			`;
+			const elements = container.querySelectorAll('.bar:not(.foo)');
+
+			expect(elements.length).toBe(1);
+			expect(elements[0]).toBe(container.children[2]);
+		});
+
+		it('Returns all elements matching "[data-foo]:not([data-bar])".', () => {
+			const container = document.createElement('div');
+			container.innerHTML = `
+				<div data-foo data-bar class="foo bar"></div>
+				<div data-foo class="foo"></div>
+				<div data-bar class="bar"></div>
+			`;
+			const elements = container.querySelectorAll('[data-foo]:not([data-bar])');
+
+			expect(elements.length).toBe(1);
+			expect(elements[0]).toBe(container.children[1]);
+		});
+
 		it('Returns all span elements matching span:nth-child(1) or span:nth-child(2).', () => {
 			const container = document.createElement('div');
 			container.innerHTML = QuerySelectorHTML;