Repository: capricorn86/happy-dom
Pull request #884 (2023-05-02 13:45:06)

#880@patch: Fixes issue where react "onChange" listener never got tri…
…ggered when triggering a click().


Patch:
diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts
index 3130329f..34c8c087 100644
--- a/packages/happy-dom/src/nodes/element/Element.ts
+++ b/packages/happy-dom/src/nodes/element/Element.ts
@@ -160,6 +160,24 @@ export default class Element extends Node implements IElement {
 		return this.tagName ? this.tagName.toLowerCase() : 'unknown';
 	}
 
+	/**
+	 * Returns role.
+	 *
+	 * @returns Role.
+	 */
+	public get role(): string {
+		return this.getAttribute('role') || '';
+	}
+
+	/**
+	 * Sets role.
+	 *
+	 * @param role Role.
+	 */
+	public set role(role: string) {
+		this.setAttribute('role', role);
+	}
+
 	/**
 	 * Previous element sibling.
 	 *
diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts
index 317bd971..ad92cf74 100644
--- a/packages/happy-dom/src/nodes/element/IElement.ts
+++ b/packages/happy-dom/src/nodes/element/IElement.ts
@@ -25,6 +25,7 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode,
 	scrollLeft: number;
 	id: string;
 	className: string;
+	role: string;
 	innerHTML: string;
 	outerHTML: string;
 	slot: string;
diff --git a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts
index 32000d30..9400c464 100644
--- a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts
+++ b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts
@@ -570,18 +570,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE
 	 * @param checked Checked.
 	 */
 	public set checked(checked: boolean) {
-		this._checked = checked;
-
-		if (checked && this.type === 'radio' && this.name) {
-			const root = <IHTMLElement>(<IHTMLFormElement>this._formNode || this.getRootNode());
-			const radioButtons = root.querySelectorAll(`input[type="radio"][name="${this.name}"]`);
-
-			for (const radioButton of radioButtons) {
-				if (radioButton !== this) {
-					radioButton['_checked'] = false;
-				}
-			}
-		}
+		this._setChecked(checked);
 	}
 
 	/**
@@ -1051,7 +1040,7 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE
 			this.isConnected &&
 			(this.type === 'checkbox' || this.type === 'radio')
 		) {
-			this.checked = this.type === 'checkbox' ? !this.checked : true;
+			this._setChecked(this.type === 'checkbox' ? !this.checked : true);
 		}
 
 		const returnValue = super.dispatchEvent(event);
@@ -1148,4 +1137,24 @@ export default class HTMLInputElement extends HTMLElement implements IHTMLInputE
 			this.type === 'password'
 		);
 	}
+
+	/**
+	 * Sets checked value.
+	 *
+	 * @param checked Checked.
+	 */
+	private _setChecked(checked: boolean): void {
+		this._checked = checked;
+
+		if (checked && this.type === 'radio' && this.name) {
+			const root = <IHTMLElement>(<IHTMLFormElement>this._formNode || this.getRootNode());
+			const radioButtons = root.querySelectorAll(`input[type="radio"][name="${this.name}"]`);
+
+			for (const radioButton of radioButtons) {
+				if (radioButton !== this) {
+					radioButton['_checked'] = false;
+				}
+			}
+		}
+	}
 }
diff --git a/packages/happy-dom/src/query-selector/ISelectorPseudo.ts b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts
new file mode 100644
index 00000000..462ff7ce
--- /dev/null
+++ b/packages/happy-dom/src/query-selector/ISelectorPseudo.ts
@@ -0,0 +1,4 @@
+export default interface ISelectorPseudo {
+	name: string;
+	arguments: string | null;
+}
diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts
index baf8df13..dff7da07 100644
--- a/packages/happy-dom/src/query-selector/SelectorItem.ts
+++ b/packages/happy-dom/src/query-selector/SelectorItem.ts
@@ -6,6 +6,7 @@ import SelectorCombinatorEnum from './SelectorCombinatorEnum';
 import ISelectorAttribute from './ISelectorAttribute';
 import SelectorParser from './SelectorParser';
 import ISelectorMatch from './ISelectorMatch';
+import ISelectorPseudo from './ISelectorPseudo';
 
 /**
  * Selector item.
@@ -16,8 +17,7 @@ export default class SelectorItem {
 	public id: string | null;
 	public classNames: string[] | null;
 	public attributes: ISelectorAttribute[] | null;
-	public pseudoClass: string | null;
-	public pseudoArguments: string | null;
+	public pseudos: ISelectorPseudo[] | null;
 	public combinator: SelectorCombinatorEnum;
 
 	/**
@@ -30,8 +30,7 @@ export default class SelectorItem {
 	 * @param [options.id] ID.
 	 * @param [options.classNames] Class names.
 	 * @param [options.attributes] Attributes.
-	 * @param [options.pseudoClass] Pseudo class.
-	 * @param [options.pseudoArguments] Pseudo arguments.
+	 * @param [options.pseudos] Pseudos.
 	 */
 	constructor(options?: {
 		all?: string;
@@ -39,8 +38,7 @@ export default class SelectorItem {
 		id?: string;
 		classNames?: string[];
 		attributes?: ISelectorAttribute[];
-		pseudoClass?: string;
-		pseudoArguments?: string;
+		pseudos?: ISelectorPseudo[];
 		combinator?: SelectorCombinatorEnum;
 	}) {
 		this.all = options?.all || null;
@@ -48,8 +46,7 @@ export default class SelectorItem {
 		this.id = options?.id || null;
 		this.classNames = options?.classNames || null;
 		this.attributes = options?.attributes || null;
-		this.pseudoClass = options?.pseudoClass || null;
-		this.pseudoArguments = options?.pseudoArguments || null;
+		this.pseudos = options?.pseudos || null;
 		this.combinator = options?.combinator || SelectorCombinatorEnum.descendant;
 	}
 
@@ -97,7 +94,7 @@ export default class SelectorItem {
 		}
 
 		// Pseudo match
-		if (this.pseudoClass && !this.matchPsuedo(element)) {
+		if (this.pseudos && !this.matchPsuedo(element)) {
 			return null;
 		}
 
@@ -113,97 +110,105 @@ export default class SelectorItem {
 	private matchPsuedo(element: IElement): boolean {
 		const parent = <IElement>element.parentNode;
 
-		// Validation
-		switch (this.pseudoClass) {
-			case 'not':
-			case 'nth-child':
-			case 'nth-of-type':
-			case 'nth-last-child':
-			case 'nth-last-of-type':
-				if (!this.pseudoArguments) {
-					throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`);
-				}
-				break;
+		if (!this.pseudos) {
+			return true;
 		}
 
-		// Check if parent exists
-		if (!parent) {
-			switch (this.pseudoClass) {
+		for (const psuedo of this.pseudos) {
+			// Validation
+			switch (psuedo.name) {
+				case 'not':
+				case 'nth-child':
+				case 'nth-of-type':
+				case 'nth-last-child':
+				case 'nth-last-of-type':
+					if (!psuedo.arguments) {
+						throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`);
+					}
+					break;
+			}
+
+			// Check if parent exists
+			if (!parent) {
+				switch (psuedo.name) {
+					case 'first-child':
+					case 'last-child':
+					case 'only-child':
+					case 'first-of-type':
+					case 'last-of-type':
+					case 'only-of-type':
+					case 'nth-child':
+					case 'nth-of-type':
+					case 'nth-last-child':
+					case 'nth-last-of-type':
+						return false;
+				}
+			}
+
+			switch (psuedo.name) {
 				case 'first-child':
+					return parent.children[0] === element;
 				case 'last-child':
+					return parent.children.length && parent.children[parent.children.length - 1] === element;
 				case 'only-child':
+					return parent.children.length === 1 && parent.children[0] === element;
 				case 'first-of-type':
+					for (const child of parent.children) {
+						if (child.tagName === element.tagName) {
+							return child === element;
+						}
+					}
+					return false;
 				case 'last-of-type':
+					for (let i = parent.children.length - 1; i >= 0; i--) {
+						const child = parent.children[i];
+						if (child.tagName === element.tagName) {
+							return child === element;
+						}
+					}
+					return false;
 				case 'only-of-type':
+					let isFound = false;
+					for (const child of parent.children) {
+						if (child.tagName === element.tagName) {
+							if (isFound || child !== element) {
+								return false;
+							}
+							isFound = true;
+						}
+					}
+					return isFound;
+				case 'checked':
+					return element.tagName === 'INPUT' && (<IHTMLInputElement>element).checked;
+				case 'empty':
+					return !element.children.length;
+				case 'root':
+					return element.tagName === 'HTML';
+				case 'not':
+					return !SelectorParser.getSelectorItem(psuedo.arguments).match(element);
 				case 'nth-child':
+					return this.matchNthChild(element, parent.children, psuedo.arguments);
 				case 'nth-of-type':
+					if (!element.parentNode) {
+						return false;
+					}
+					return this.matchNthChild(
+						element,
+						parent.children.filter((child) => child.tagName === element.tagName),
+						psuedo.arguments
+					);
 				case 'nth-last-child':
+					return this.matchNthChild(element, parent.children.reverse(), psuedo.arguments);
 				case 'nth-last-of-type':
-					return false;
+					return this.matchNthChild(
+						element,
+						parent.children.filter((child) => child.tagName === element.tagName).reverse(),
+						psuedo.arguments
+					);
 			}
 		}
 
-		switch (this.pseudoClass) {
-			case 'first-child':
-				return parent.children[0] === element;
-			case 'last-child':
-				return parent.children.length && parent.children[parent.children.length - 1] === element;
-			case 'only-child':
-				return parent.children.length === 1 && parent.children[0] === element;
-			case 'first-of-type':
-				for (const child of parent.children) {
-					if (child.tagName === element.tagName) {
-						return child === element;
-					}
-				}
-				return false;
-			case 'last-of-type':
-				for (let i = parent.children.length - 1; i >= 0; i--) {
-					const child = parent.children[i];
-					if (child.tagName === element.tagName) {
-						return child === element;
-					}
-				}
-				return false;
-			case 'only-of-type':
-				let isFound = false;
-				for (const child of parent.children) {
-					if (child.tagName === element.tagName) {
-						if (isFound || child !== element) {
-							return false;
-						}
-						isFound = true;
-					}
-				}
-				return isFound;
-			case 'checked':
-				return element.tagName === 'INPUT' && (<IHTMLInputElement>element).checked;
-			case 'empty':
-				return !element.children.length;
-			case 'root':
-				return element.tagName === 'HTML';
-			case 'not':
-				return !SelectorParser.getSelectorItem(this.pseudoArguments).match(element);
-			case 'nth-child':
-				return this.matchNthChild(element, parent.children, this.pseudoArguments);
-			case 'nth-of-type':
-				if (!element.parentNode) {
-					return false;
-				}
-				return this.matchNthChild(
-					element,
-					parent.children.filter((child) => child.tagName === element.tagName),
-					this.pseudoArguments
-				);
-			case 'nth-last-child':
-				return this.matchNthChild(element, parent.children.reverse(), this.pseudoArguments);
-			case 'nth-last-of-type':
-				return this.matchNthChild(
-					element,
-					parent.children.filter((child) => child.tagName === element.tagName).reverse(),
-					this.pseudoArguments
-				);
-		}
+		return true;
 	}
 
 	/**
@@ -362,6 +367,12 @@ export default class SelectorItem {
 						)
 						.join('')
 				: ''
-		}${this.pseudoClass || ''}${this.pseudoArguments ? `(${this.pseudoArguments})` : ''}`;
+		}${
+			this.pseudos
+				? this.pseudos
+						.map((pseudo) => `:${pseudo.name}${pseudo.arguments ? `(${pseudo.arguments})` : ''}`)
+						.join('')
+				: ''
+		}`;
 	}
 }
diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts
index 8bce953b..a38f63cc 100644
--- a/packages/happy-dom/src/query-selector/SelectorParser.ts
+++ b/packages/happy-dom/src/query-selector/SelectorParser.ts
@@ -13,12 +13,13 @@ import DOMException from '../exception/DOMException';
  * Group 6: Attribute name when there is a value (e.g. "attr1")
  * Group 7: Attribute operator (e.g. "~")
  * Group 8: Attribute value (e.g. "value1")
- * Group 9: Pseudo (e.g. "nth-child")
+ * Group 9: Pseudo name when arguments (e.g. "nth-child")
  * Group 10: Arguments of pseudo (e.g. "2n + 1")
- * Group 11: Combinator.
+ * Group 11: Pseudo name when no arguments (e.g. "empty")
+ * Group 12: Combinator.
  */
 const SELECTOR_REGEXP =
-	/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1}) *= *["']{0,1}([^"']*)["']{0,1}\]|:([a-zA-Z-:]+)|\(([^)]+)\)|([ ,+>]*)/g;
+	/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1}) *= *["']{0,1}([^"']*)["']{0,1}\]|:([a-zA-Z-]+) *\(([^)]+)\)|:([a-zA-Z-]+)|([ ,+>]*)/g;
 
 /**
  * Escaped Character RegExp.
@@ -103,12 +104,20 @@ export default class SelectorParser {
 						operator: match[7] || null,
 						value: match[8]
 					});
-				} else if (match[9]) {
-					currentSelectorItem.pseudoClass = match[9].toLowerCase();
-				} else if (match[10]) {
-					currentSelectorItem.pseudoArguments = match[10];
+				} else if (match[9] && match[10]) {
+					currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
+					currentSelectorItem.pseudos.push({
+						name: match[9].toLowerCase(),
+						arguments: match[10]
+					});
 				} else if (match[11]) {
-					switch (match[11].trim()) {
+					currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
+					currentSelectorItem.pseudos.push({
+						name: match[11].toLowerCase(),
+						arguments: null
+					});
+				} else if (match[12]) {
+					switch (match[12].trim()) {
 						case ',':
 							currentSelectorItem = new SelectorItem({
 								combinator: SelectorCombinatorEnum.descendant


Test patch:
diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts
index 2175a197..ff8f5791 100644
--- a/packages/happy-dom/test/nodes/element/Element.test.ts
+++ b/packages/happy-dom/test/nodes/element/Element.test.ts
@@ -87,6 +87,27 @@ describe('Element', () => {
 		});
 	});
 
+	describe('set className()', () => {
+		it('Sets the element "class" as an attribute.', () => {
+			element.className = 'class';
+			expect(element.getAttribute('class')).toBe('class');
+		});
+	});
+
+	describe('get role()', () => {
+		it('Returns the element "role" attribute.', () => {
+			element.setAttribute('role', 'role');
+			expect(element.role).toBe('role');
+		});
+	});
+
+	describe('set role()', () => {
+		it('Sets the element "role" as an attribute.', () => {
+			element.role = 'role';
+			expect(element.getAttribute('role')).toBe('role');
+		});
+	});
+
 	describe('get classList()', () => {
 		it('Returns a DOMTokenList object.', () => {
 			element.setAttribute('class', 'class1');
@@ -94,13 +115,6 @@ describe('Element', () => {
 		});
 	});
 
-	describe('set id()', () => {
-		it('Sets the element "class" as an attribute.', () => {
-			element.className = 'class';
-			expect(element.getAttribute('class')).toBe('class');
-		});
-	});
-
 	describe('get namespaceURI()', () => {
 		it('Returns the "namespaceURI" property of the element.', () => {
 			expect(element.namespaceURI).toEqual(NamespaceURI.html);
diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts
index 04bd62da..3090c4b7 100644
--- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts
+++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts
@@ -618,6 +618,20 @@ describe('QuerySelector', () => {
 			expect(elements[1] === container.children[0].children[1].children[2]).toBe(true);
 		});
 
+		it('Returns all elements matching "input:not([type]):not([list])" to verify that "screen.getByRole(\'checkbox\')" works in Testing Library.', () => {
+			const container = document.createElement('div');
+
+			container.innerHTML = '<input type="checkbox"></input>';
+
+			expect(container.querySelectorAll('input:not([type]):not([list])').length).toBe(0);
+
+			container.innerHTML = '<input></input>';
+
+			const elements = container.querySelectorAll('input:not([type]):not([list])');
+			expect(elements.length).toBe(1);
+			expect(elements[0] === container.children[0]).toBe(true);
+		});
+
 		it('Returns all elements matching ".foo:not(.bar)".', () => {
 			const container = document.createElement('div');
 			container.innerHTML = `
diff --git a/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx b/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx
index 67ef17c2..a1907421 100644
--- a/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx
+++ b/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx
@@ -32,4 +32,15 @@ describe('TestingLibrary', () => {
 		expect(handleSubmit).toHaveBeenCalledTimes(1);
 		expect(clickHandler).toHaveBeenCalledTimes(1);
 	});
+
+	it('Triggers change event once.', async () => {
+		const user = userEvent.setup();
+		const changeHandler = jest.fn();
+
+		render(<input type="checkbox" onChange={changeHandler} />);
+
+		await user.click(screen.getByRole('checkbox'));
+
+		expect(changeHandler).toHaveBeenCalledTimes(1);
+	});
 });