Repository: capricorn86/happy-dom (TypeScript)

Issue:
Element.classList has empty string item when a class attribute contains multiple spaces
**Describe the bug**
Element.classList has empty string item when a class attribute contains multiple spaces. Multiple spaces should be compressed  and Element.classList should not have empty string item.

**To Reproduce**
Steps to reproduce the behavior:
1. Run following code:

```js
const {Window} = require('happy-dom');
const {document} = new Window();
const element = document.createElement('div');
element.className = 'a  b';
const list = [...element.classList.values()];
console.log(list); // ['a', '', 'b']
```

**Expected behavior**
The expected result of `list` is `['a', 'b']`. Other DOM implenetations including Google Chrome, Mozilla Firefox, and JSDOM return `['a', 'b']`.

**Device:**
 - OS: Windows 11
 - Browser: Google Chrome and Mozilla Fireefox
 - Version; happy-dom 12.10.3



Discussion:

Patch:
diff --git a/packages/happy-dom/src/dom-token-list/DOMTokenList.ts b/packages/happy-dom/src/dom-token-list/DOMTokenList.ts
index 04b40e89c..a36a24102 100644
--- a/packages/happy-dom/src/dom-token-list/DOMTokenList.ts
+++ b/packages/happy-dom/src/dom-token-list/DOMTokenList.ts
@@ -2,6 +2,8 @@ import Element from '../nodes/element/Element.js';
 import * as PropertySymbol from '../PropertySymbol.js';
 import IDOMTokenList from './IDOMTokenList.js';
 
+const ATTRIBUTE_SPLIT_REGEXP = /[\t\f\n\r ]+/;
+
 /**
  * DOM Token List.
  *
@@ -9,7 +11,7 @@ import IDOMTokenList from './IDOMTokenList.js';
  * https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList.
  */
 export default class DOMTokenList implements IDOMTokenList {
-	public readonly length = 0;
+	#length = 0;
 	#ownerElement: Element;
 	#attributeName: string;
 
@@ -25,6 +27,15 @@ export default class DOMTokenList implements IDOMTokenList {
 		this[PropertySymbol.updateIndices]();
 	}
 
+	/**
+	 * Returns length.
+	 *
+	 * @returns Length.
+	 */
+	public get length(): number {
+		return this.#length;
+	}
+
 	/**
 	 * Set value.
 	 *
@@ -58,8 +69,7 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * @param newToken NewToken.
 	 */
 	public replace(token: string, newToken: string): boolean {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
+		const list = this.#getTokenList();
 		const index = list.indexOf(token);
 		if (index === -1) {
 			return false;
@@ -82,18 +92,14 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object.
 	 */
 	public values(): IterableIterator<string> {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
-		return list.values();
+		return this.#getTokenList().values();
 	}
 
 	/**
 	 * Returns an iterator, allowing you to go through all key/value pairs contained in this object.
 	 */
 	public entries(): IterableIterator<[number, string]> {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
-		return list.entries();
+		return this.#getTokenList().entries();
 	}
 
 	/**
@@ -103,9 +109,7 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * @param thisArg
 	 */
 	public forEach(callback: (currentValue, currentIndex, listObj) => void, thisArg?: this): void {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
-		return list.forEach(callback, thisArg);
+		return this.#getTokenList().forEach(callback, thisArg);
 	}
 
 	/**
@@ -113,9 +117,7 @@ export default class DOMTokenList implements IDOMTokenList {
 	 *
 	 */
 	public keys(): IterableIterator<number> {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
-		return list.keys();
+		return this.#getTokenList().keys();
 	}
 
 	/**
@@ -124,8 +126,7 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * @param tokens Tokens.
 	 */
 	public add(...tokens: string[]): void {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
+		const list = this.#getTokenList();
 
 		for (const token of tokens) {
 			const index = list.indexOf(token);
@@ -145,8 +146,7 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * @param tokens Tokens.
 	 */
 	public remove(...tokens: string[]): void {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
+		const list = this.#getTokenList();
 
 		for (const token of tokens) {
 			const index = list.indexOf(token);
@@ -165,8 +165,8 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * @returns TRUE if it contains.
 	 */
 	public contains(className: string): boolean {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		return (attr ? attr.split(' ') : []).includes(className);
+		const list = this.#getTokenList();
+		return list.includes(className);
 	}
 
 	/**
@@ -199,8 +199,7 @@ export default class DOMTokenList implements IDOMTokenList {
 	 * Updates indices.
 	 */
 	public [PropertySymbol.updateIndices](): void {
-		const attr = this.#ownerElement.getAttribute(this.#attributeName);
-		const list = attr ? Array.from(new Set(attr.split(' '))) : [];
+		const list = this.#getTokenList();
 
 		for (let i = list.length - 1, max = this.length; i < max; i++) {
 			delete this[i];
@@ -210,7 +209,27 @@ export default class DOMTokenList implements IDOMTokenList {
 			this[i] = list[i];
 		}
 
-		(<number>this.length) = list.length;
+		this.#length = list.length;
+	}
+
+	/**
+	 * Returns token list from attribute value.
+	 *
+	 * @see https://infra.spec.whatwg.org/#split-on-ascii-whitespace
+	 */
+	#getTokenList(): string[] {
+		const attr = this.#ownerElement.getAttribute(this.#attributeName);
+		if (!attr) {
+			return [];
+		}
+		// It is possible to make this statement shorter by using Array.from() and Set, but this is faster when comparing using a bench test.
+		const list = [];
+		for (const item of attr.trim().split(ATTRIBUTE_SPLIT_REGEXP)) {
+			if (!list.includes(item)) {
+				list.push(item);
+			}
+		}
+		return list;
 	}
 
 	/**


Test patch:
diff --git a/packages/happy-dom/test/dom-token-list/DOMTokenList.test.ts b/packages/happy-dom/test/dom-token-list/DOMTokenList.test.ts
index b57aa0149..3951c7c7a 100644
--- a/packages/happy-dom/test/dom-token-list/DOMTokenList.test.ts
+++ b/packages/happy-dom/test/dom-token-list/DOMTokenList.test.ts
@@ -211,4 +211,12 @@ describe('DOMTokenList', () => {
 			expect(element.classList.toString()).toEqual('class1 class2  class3');
 		});
 	});
+
+	describe('whitespace handling', () => {
+		it('Normalizes whitespace to a single space', () => {
+			element.className = ' class1  class2\nclass3 ';
+			expect(Array.from(element.classList.values())).toEqual(['class1', 'class2', 'class3']);
+			expect(element.classList.toString()).toEqual(' class1  class2\nclass3 ');
+		});
+	});
 });