diff --git a/packages/global-registrator/tsconfig.json b/packages/global-registrator/tsconfig.json index 73f85b24..407ec16f 100644 --- a/packages/global-registrator/tsconfig.json +++ b/packages/global-registrator/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "lib", "rootDir": "src", - "target": "ES2020", + "target": "ES2022", "declaration": true, "declarationMap": true, "module": "Node16", diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts index 1dfa3758..80bedb4d 100644 --- a/packages/happy-dom/src/PropertySymbol.ts +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -1,7 +1,5 @@ export const abort = Symbol('abort'); export const activeElement = Symbol('activeElement'); -export const appendFormControlItem = Symbol('appendFormControlItem'); -export const appendNamedItem = Symbol('appendNamedItem'); export const asyncTaskManager = Symbol('asyncTaskManager'); export const bodyBuffer = Symbol('bodyBuffer'); export const buffer = Symbol('buffer'); @@ -14,7 +12,8 @@ export const childNodes = Symbol('childNodes'); export const children = Symbol('children'); export const classList = Symbol('classList'); export const computedStyle = Symbol('computedStyle'); -export const connectToNode = Symbol('connectToNode'); +export const connectedToDocument = Symbol('connectedToDocument'); +export const disconnectedFromDocument = Symbol('disconnectedFromDocument'); export const contentLength = Symbol('contentLength'); export const contentType = Symbol('contentType'); export const cssText = Symbol('cssText'); @@ -53,16 +52,11 @@ export const readyStateManager = Symbol('readyStateManager'); export const referrer = Symbol('referrer'); export const registry = Symbol('registry'); export const relList = Symbol('relList'); -export const removeFormControlItem = Symbol('removeFormControlItem'); -export const removeNamedItem = Symbol('removeNamedItem'); -export const removeNamedItemIndex = Symbol('removeNamedItemIndex'); -export const removeNamedItemWithoutConsequences = Symbol('removeNamedItemWithoutConsequences'); export const resetSelection = Symbol('resetSelection'); export const rootNode = Symbol('rootNode'); export const selectNode = Symbol('selectNode'); export const selectedness = Symbol('selectedness'); export const selection = Symbol('selection'); -export const setNamedItemWithoutConsequences = Symbol('setNamedItemWithoutConsequences'); export const setupVMContext = Symbol('setupVMContext'); export const shadowRoot = Symbol('shadowRoot'); export const start = Symbol('start'); @@ -170,3 +164,30 @@ export const capabilities = Symbol('capabilities'); export const settings = Symbol('settings'); export const dataListNode = Symbol('dataListNode'); export const setNamedItem = Symbol('setNamedItem'); +export const fieldSetNode = Symbol('fieldSetNode'); +export const addRemoveListener = Symbol('addRemoveListener'); +export const addSetListener = Symbol('addSetListener'); +export const removeSetListener = Symbol('removeSetListener'); +export const removeRemoveListener = Symbol('removeRemoveListener'); +export const appendFormControlItemByName = Symbol('appendFormControlItemByName'); +export const removeFormControlItemByName = Symbol('removeFormControlItemByName'); +export const clone = Symbol('clone'); +export const addItem = Symbol('addItem'); +export const addNamedItem = Symbol('addNamedItem'); +export const removeItem = Symbol('removeItem'); +export const removeNamedItem = Symbol('removeNamedItem'); +export const items = Symbol('items'); +export const removeItemIndex = Symbol('removeItemIndex'); +export const indexOf = Symbol('indexOf'); +export const updateNamedItem = Symbol('updateNamedItem'); +export const childNodesFlatten = Symbol('childNodesFlatten'); +export const includes = Symbol('includes'); +export const insertItem = Symbol('insertItem'); +export const addEventListener = Symbol('addEventListener'); +export const removeEventListener = Symbol('removeEventListener'); +export const htmlCollections = Symbol('htmlCollections'); +export const namedItemListeners = Symbol('namedItemListeners'); +export const dispatchEvent = Symbol('dispatchEvent'); +export const getNamedItems = Symbol('getNamedItems'); +export const setNamedItemProperty = Symbol('setNamedItemProperty'); +export const selectedOptions = Symbol('selectedOptions'); diff --git a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts index 96ec4e38..de312663 100644 --- a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts +++ b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts @@ -6,7 +6,7 @@ import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import DOMException from '../../exception/DOMException.js'; import CSSStyleDeclarationElementStyle from './element-style/CSSStyleDeclarationElementStyle.js'; import CSSStyleDeclarationPropertyManager from './property-manager/CSSStyleDeclarationPropertyManager.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; +import NamedNodeMap from '../../nodes/element/NamedNodeMap.js'; /** * CSS Style Declaration. @@ -83,10 +83,10 @@ export default abstract class AbstractCSSStyleDeclaration { if (!styleAttribute) { styleAttribute = this.#ownerElement[PropertySymbol.ownerDocument].createAttribute('style'); - // We use "[PropertySymbol.setNamedItemWithoutConsequences]" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. - (this.#ownerElement[PropertySymbol.attributes])[ - PropertySymbol.setNamedItemWithoutConsequences - ](styleAttribute); + (this.#ownerElement[PropertySymbol.attributes])[PropertySymbol.setNamedItem]( + styleAttribute, + true + ); } if (this.#ownerElement[PropertySymbol.isConnected]) { @@ -141,10 +141,10 @@ export default abstract class AbstractCSSStyleDeclaration { if (!styleAttribute) { styleAttribute = this.#ownerElement[PropertySymbol.ownerDocument].createAttribute('style'); - // We use "[PropertySymbol.setNamedItemWithoutConsequences]" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. - (this.#ownerElement[PropertySymbol.attributes])[ - PropertySymbol.setNamedItemWithoutConsequences - ](styleAttribute); + (this.#ownerElement[PropertySymbol.attributes])[PropertySymbol.setNamedItem]( + styleAttribute, + true + ); } if (this.#ownerElement[PropertySymbol.isConnected]) { @@ -188,10 +188,9 @@ export default abstract class AbstractCSSStyleDeclaration { (this.#ownerElement[PropertySymbol.attributes]['style'])[PropertySymbol.value] = newCSSText; } else { - // We use "[PropertySymbol.removeNamedItemWithoutConsequences]" here to avoid triggering setting "Element.style.cssText" when setting the "style" attribute. (this.#ownerElement[PropertySymbol.attributes])[ - PropertySymbol.removeNamedItemWithoutConsequences - ]('style'); + PropertySymbol.removeNamedItem + ]('style', true); } } else { this.#style.remove(name); diff --git a/packages/happy-dom/src/dom-parser/DOMParser.ts b/packages/happy-dom/src/dom-parser/DOMParser.ts index 3ee3e29d..85d80c2e 100644 --- a/packages/happy-dom/src/dom-parser/DOMParser.ts +++ b/packages/happy-dom/src/dom-parser/DOMParser.ts @@ -38,14 +38,15 @@ export default class DOMParser { const newDocument = this.#createDocument(mimeType); - newDocument[PropertySymbol.childNodes].length = 0; - newDocument[PropertySymbol.children].length = 0; + while (newDocument[PropertySymbol.childNodes][PropertySymbol.items].length) { + newDocument.removeChild(newDocument[PropertySymbol.childNodes][PropertySymbol.items][0]); + } const root = XMLParser.parse(newDocument, string, { evaluateScripts: true }); let documentElement = null; let documentTypeNode = null; - for (const node of root[PropertySymbol.childNodes]) { + for (const node of root[PropertySymbol.childNodes][PropertySymbol.items]) { if (node['tagName'] === 'HTML') { documentElement = node; } else if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { @@ -64,16 +65,16 @@ export default class DOMParser { newDocument.appendChild(documentElement); const body = newDocument.body; if (body) { - for (const child of root[PropertySymbol.childNodes].slice()) { - body.appendChild(child); + while (root[PropertySymbol.childNodes][PropertySymbol.items].length) { + body.appendChild(root[PropertySymbol.childNodes][PropertySymbol.items][0]); } } } else { switch (mimeType) { case 'image/svg+xml': { - for (const node of root[PropertySymbol.childNodes].slice()) { - newDocument.appendChild(node); + while (root[PropertySymbol.childNodes][PropertySymbol.items].length) { + newDocument.appendChild(root[PropertySymbol.childNodes][PropertySymbol.items][0]); } } break; @@ -88,8 +89,8 @@ export default class DOMParser { documentElement.appendChild(bodyElement); newDocument.appendChild(documentElement); - for (const node of root[PropertySymbol.childNodes].slice()) { - bodyElement.appendChild(node); + while (root[PropertySymbol.childNodes][PropertySymbol.items].length) { + bodyElement.appendChild(root[PropertySymbol.childNodes][PropertySymbol.items][0]); } } break; diff --git a/packages/happy-dom/src/form-data/FormData.ts b/packages/happy-dom/src/form-data/FormData.ts index 0fd240e8..5a6feb42 100644 --- a/packages/happy-dom/src/form-data/FormData.ts +++ b/packages/happy-dom/src/form-data/FormData.ts @@ -59,8 +59,8 @@ export default class FormData implements Iterable<[string, string | File]> { this.append(node.name, file); } } - } else if (node.value) { - this.append(node.name, node.value); + } else if ((node).value) { + this.append(node.name, (node).value); } } } diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index 3cfa631f..0ae14273 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -72,7 +72,7 @@ import DocumentType from './nodes/document-type/DocumentType.js'; import Document from './nodes/document/Document.js'; import DOMRect from './nodes/element/DOMRect.js'; import Element from './nodes/element/Element.js'; -import HTMLCollection from './nodes/element/HTMLCollection.js'; +import HTMLCollection from './nodes/element/HTMLCollection2.js'; import HTMLAnchorElement from './nodes/html-anchor-element/HTMLAnchorElement.js'; import HTMLAreaElement from './nodes/html-area-element/HTMLAreaElement.js'; import HTMLAudioElement from './nodes/html-audio-element/HTMLAudioElement.js'; diff --git a/packages/happy-dom/src/named-node-map/NamedNodeMap.ts b/packages/happy-dom/src/named-node-map/NamedNodeMap.ts deleted file mode 100644 index 5fb0f942..00000000 --- a/packages/happy-dom/src/named-node-map/NamedNodeMap.ts +++ /dev/null @@ -1,246 +0,0 @@ -import * as PropertySymbol from '../PropertySymbol.js'; -import Attr from '../nodes/attr/Attr.js'; -import DOMException from '../exception/DOMException.js'; -import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class NamedNodeMap { - [index: number]: Attr; - public length = 0; - protected [PropertySymbol.namedItems]: { [k: string]: Attr } = {}; - - /** - * Returns string. - * - * @returns string. - */ - public get [Symbol.toStringTag](): string { - return 'NamedNodeMap'; - } - - /** - * Iterator. - * - * @returns Iterator. - */ - public *[Symbol.iterator](): IterableIterator { - for (let i = 0, max = this.length; i < max; i++) { - yield this[i]; - } - } - - /** - * Returns item by index. - * - * @param index Index. - */ - public item(index: number): Attr | null { - return index >= 0 && this[index] ? this[index] : null; - } - - /** - * Returns named item. - * - * @param name Name. - * @returns Item. - */ - public getNamedItem(name: string): Attr | null { - return this[PropertySymbol.namedItems][name] || null; - } - - /** - * Returns item by name and namespace. - * - * @param namespace Namespace. - * @param localName Local name of the attribute. - * @returns Item. - */ - public getNamedItemNS(namespace: string, localName: string): Attr | null { - const attribute = this.getNamedItem(localName); - - if ( - attribute && - attribute[PropertySymbol.namespaceURI] === namespace && - attribute.localName === localName - ) { - return attribute; - } - - for (let i = 0, max = this.length; i < max; i++) { - if (this[i][PropertySymbol.namespaceURI] === namespace && this[i].localName === localName) { - return this[i]; - } - } - - return null; - } - - /** - * Sets named item. - * - * @param item Item. - * @returns Replaced item. - */ - public setNamedItem(item: Attr): Attr | null { - return this[PropertySymbol.setNamedItem](item); - } - - /** - * Adds a new namespaced item. - * - * @alias setNamedItem() - * @param item Item. - * @returns Replaced item. - */ - public setNamedItemNS(item: Attr): Attr | null { - return this[PropertySymbol.setNamedItem](item); - } - - /** - * Removes an item. - * - * @throws DOMException - * @param name Name of item. - * @returns Removed item. - */ - public removeNamedItem(name: string): Attr { - const item = this[PropertySymbol.removeNamedItem](name); - if (!item) { - throw new DOMException( - `Failed to execute 'removeNamedItem' on 'NamedNodeMap': No item with name '${name}' was found.`, - DOMExceptionNameEnum.notFoundError - ); - } - return item; - } - - /** - * Removes a namespaced item. - * - * @param namespace Namespace. - * @param localName Local name of the item. - * @returns Removed item. - */ - public removeNamedItemNS(namespace: string, localName: string): Attr | null { - const attribute = this.getNamedItemNS(namespace, localName); - if (attribute) { - return this.removeNamedItem(attribute[PropertySymbol.name]); - } - return null; - } - - /** - * Sets named item. - * - * This method may be overridden by subclasses to act on attribute changes. - * - * @param item Item. - * @returns Replaced item. - */ - public [PropertySymbol.setNamedItem](item: Attr): Attr | null { - return this[PropertySymbol.setNamedItemWithoutConsequences](item); - } - - /** - * Sets named item without potential overrides done in [PropertySymbol.setNamedItem](). - * - * @param item Item. - * @returns Replaced item. - */ - public [PropertySymbol.setNamedItemWithoutConsequences](item: Attr): Attr | null { - if (item[PropertySymbol.name]) { - const replacedItem = this[PropertySymbol.namedItems][item[PropertySymbol.name]] || null; - - this[PropertySymbol.namedItems][item[PropertySymbol.name]] = item; - - if (replacedItem) { - this[PropertySymbol.removeNamedItemIndex](replacedItem); - } - - this[this.length] = item; - this.length++; - - if (this[PropertySymbol.isValidPropertyName](item[PropertySymbol.name])) { - this[item[PropertySymbol.name]] = item; - } - - return replacedItem; - } - return null; - } - - /** - * Removes an item without throwing if it doesn't exist. - * - * This method may be overridden by subclasses to act on attribute changes. - * - * @param name Name of item. - * @returns Removed item, or null if it didn't exist. - */ - public [PropertySymbol.removeNamedItem](name: string): Attr | null { - return this[PropertySymbol.removeNamedItemWithoutConsequences](name); - } - - /** - * Removes an item without calling listeners for certain attributes. - * - * @param name Name of item. - * @returns Removed item, or null if it didn't exist. - */ - public [PropertySymbol.removeNamedItemWithoutConsequences](name: string): Attr | null { - const removedItem = this[PropertySymbol.namedItems][name] || null; - - if (!removedItem) { - return null; - } - - this[PropertySymbol.removeNamedItemIndex](removedItem); - - if (this[name] === removedItem) { - delete this[name]; - } - - delete this[PropertySymbol.namedItems][name]; - - return removedItem; - } - - /** - * Removes an item from index. - * - * @param item Item. - */ - protected [PropertySymbol.removeNamedItemIndex](item: Attr): void { - for (let i = 0; i < this.length; i++) { - if (this[i] === item) { - for (let b = i; b < this.length; b++) { - if (b < this.length - 1) { - this[b] = this[b + 1]; - } else { - delete this[b]; - } - } - this.length--; - break; - } - } - } - - /** - * Returns "true" if the property name is valid. - * - * @param name Name. - * @returns True if the property name is valid. - */ - protected [PropertySymbol.isValidPropertyName](name: string): boolean { - return ( - !!name && - !this.constructor.prototype.hasOwnProperty(name) && - (isNaN(Number(name)) || name.includes('.')) - ); - } -} diff --git a/packages/happy-dom/src/nodes/attr/Attr.ts b/packages/happy-dom/src/nodes/attr/Attr.ts index cb40b3b6..f8de7996 100644 --- a/packages/happy-dom/src/nodes/attr/Attr.ts +++ b/packages/happy-dom/src/nodes/attr/Attr.ts @@ -9,6 +9,9 @@ import NodeTypeEnum from '../node/NodeTypeEnum.js'; * Reference: https://developer.mozilla.org/en-US/docs/Web/API/Attr. */ export default class Attr extends Node implements Attr { + // Public properties + public cloneNode: (deep?: boolean) => Attr; + public [PropertySymbol.nodeType] = NodeTypeEnum.attributeNode; public [PropertySymbol.namespaceURI]: string | null = null; public [PropertySymbol.name]: string | null = null; diff --git a/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts b/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts index 2a39e386..11d7da6d 100644 --- a/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts +++ b/packages/happy-dom/src/nodes/child-node/ChildNodeUtility.ts @@ -39,9 +39,9 @@ export default class ChildNodeUtility { if (typeof node === 'string') { const newChildNodes = (( XMLParser.parse(childNode[PropertySymbol.ownerDocument], node) - ))[PropertySymbol.childNodes].slice(); - for (const newChildNode of newChildNodes) { - parent.insertBefore(newChildNode, childNode); + ))[PropertySymbol.childNodes][PropertySymbol.items]; + while (newChildNodes.length) { + parent.insertBefore(newChildNodes[0], childNode); } } else { parent.insertBefore(node, childNode); @@ -68,9 +68,9 @@ export default class ChildNodeUtility { if (typeof node === 'string') { const newChildNodes = (( XMLParser.parse(childNode[PropertySymbol.ownerDocument], node) - ))[PropertySymbol.childNodes].slice(); - for (const newChildNode of newChildNodes) { - parent.insertBefore(newChildNode, childNode); + ))[PropertySymbol.childNodes][PropertySymbol.items]; + while (newChildNodes.length) { + parent.insertBefore(newChildNodes[0], childNode); } } else { parent.insertBefore(node, childNode); @@ -97,12 +97,12 @@ export default class ChildNodeUtility { if (typeof node === 'string') { const newChildNodes = (( XMLParser.parse(childNode[PropertySymbol.ownerDocument], node) - ))[PropertySymbol.childNodes].slice(); - for (const newChildNode of newChildNodes) { + ))[PropertySymbol.childNodes][PropertySymbol.items]; + while (newChildNodes.length) { if (!nextSibling) { - parent.appendChild(newChildNode); + parent.appendChild(newChildNodes[0]); } else { - parent.insertBefore(newChildNode, nextSibling); + parent.insertBefore(newChildNodes[0], nextSibling); } } } else if (!nextSibling) { diff --git a/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts b/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts index ffa1f720..a8d2af70 100644 --- a/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts +++ b/packages/happy-dom/src/nodes/document-fragment/DocumentFragment.ts @@ -3,8 +3,7 @@ import * as PropertySymbol from '../../PropertySymbol.js'; import Element from '../element/Element.js'; import QuerySelector from '../../query-selector/QuerySelector.js'; import ParentNodeUtility from '../parent-node/ParentNodeUtility.js'; -import HTMLCollection from '../element/HTMLCollection.js'; -import ElementUtility from '../element/ElementUtility.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; import NodeList from '../node/NodeList.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; import IHTMLElementTagNameMap from '../../config/IHTMLElementTagNameMap.js'; @@ -19,6 +18,16 @@ export default class DocumentFragment extends Node { public [PropertySymbol.nodeType] = NodeTypeEnum.documentFragmentNode; public cloneNode: (deep?: boolean) => DocumentFragment; + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.childNodes][PropertySymbol.attachHTMLCollection]( + this[PropertySymbol.children] + ); + } + /** * Returns the document fragment children. */ @@ -32,7 +41,7 @@ export default class DocumentFragment extends Node { * @returns Element. */ public get childElementCount(): number { - return this[PropertySymbol.children].length; + return this[PropertySymbol.children][PropertySymbol.items].length; } /** @@ -41,7 +50,7 @@ export default class DocumentFragment extends Node { * @returns Element. */ public get firstElementChild(): Element { - return this[PropertySymbol.children][0] ?? null; + return this[PropertySymbol.children][PropertySymbol.items][0] ?? null; } /** @@ -50,7 +59,8 @@ export default class DocumentFragment extends Node { * @returns Element. */ public get lastElementChild(): Element { - return this[PropertySymbol.children][this[PropertySymbol.children].length - 1] ?? null; + const children = this[PropertySymbol.children][PropertySymbol.items]; + return children[children.length - 1] ?? null; } /** @@ -60,7 +70,7 @@ export default class DocumentFragment extends Node { */ public get textContent(): string { let result = ''; - for (const childNode of this[PropertySymbol.childNodes]) { + for (const childNode of this[PropertySymbol.childNodes][PropertySymbol.items]) { if ( childNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode || childNode[PropertySymbol.nodeType] === NodeTypeEnum.textNode @@ -77,8 +87,9 @@ export default class DocumentFragment extends Node { * @param textContent Text content. */ public set textContent(textContent: string) { - for (const child of this[PropertySymbol.childNodes].slice()) { - this.removeChild(child); + const childNodes = this[PropertySymbol.childNodes][PropertySymbol.items]; + while (childNodes.length) { + this.removeChild(childNodes[0]); } if (textContent) { this.appendChild(this[PropertySymbol.ownerDocument].createTextNode(textContent)); @@ -194,48 +205,7 @@ export default class DocumentFragment extends Node { * @param id ID. * @returns Matching element. */ - public getElementById(id: string): Element { + public getElementById(id: string): Element | null { return ParentNodeUtility.getElementById(this, id); } - - /** - * @override - */ - public override [PropertySymbol.cloneNode](deep = false): DocumentFragment { - const clone = super[PropertySymbol.cloneNode](deep); - - if (deep) { - for (const node of clone[PropertySymbol.childNodes]) { - if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { - clone[PropertySymbol.children].push(node); - } - } - } - - return clone; - } - - /** - * @override - */ - public override [PropertySymbol.appendChild](node: Node): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.appendChild(this, node); - } - - /** - * @override - */ - public override [PropertySymbol.removeChild](node: Node): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.removeChild(this, node); - } - - /** - * @override - */ - public override [PropertySymbol.insertBefore](newNode: Node, referenceNode: Node | null): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.insertBefore(this, newNode, referenceNode); - } } diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 44c0b560..582d451e 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -20,7 +20,7 @@ import HTMLElement from '../html-element/HTMLElement.js'; import Comment from '../comment/Comment.js'; import Text from '../text/Text.js'; import NodeList from '../node/NodeList.js'; -import HTMLCollection from '../element/HTMLCollection.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; import HTMLLinkElement from '../html-link-element/HTMLLinkElement.js'; import HTMLStyleElement from '../html-style-element/HTMLStyleElement.js'; import DocumentReadyStateEnum from './DocumentReadyStateEnum.js'; @@ -31,7 +31,6 @@ import Range from '../../range/Range.js'; import HTMLBaseElement from '../html-base-element/HTMLBaseElement.js'; import Attr from '../attr/Attr.js'; import ProcessingInstruction from '../processing-instruction/ProcessingInstruction.js'; -import ElementUtility from '../element/ElementUtility.js'; import VisibilityStateEnum from './VisibilityStateEnum.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; import CookieStringUtility from '../../cookie/urilities/CookieStringUtility.js'; @@ -197,6 +196,9 @@ export default class Document extends Node { super(); this.#browserFrame = injected.browserFrame; this[PropertySymbol.ownerWindow] = injected.window; + this[PropertySymbol.childNodes][PropertySymbol.attachHTMLCollection]( + this[PropertySymbol.children] + ); } /** @@ -328,7 +330,7 @@ export default class Document extends Node { * @returns Element. */ public get childElementCount(): number { - return this[PropertySymbol.children].length; + return this[PropertySymbol.children][PropertySymbol.items].length; } /** @@ -337,7 +339,7 @@ export default class Document extends Node { * @returns Element. */ public get firstElementChild(): Element { - return this[PropertySymbol.children][0] ?? null; + return this[PropertySymbol.children][PropertySymbol.items][0] ?? null; } /** @@ -346,7 +348,8 @@ export default class Document extends Node { * @returns Element. */ public get lastElementChild(): Element { - return this[PropertySymbol.children][this[PropertySymbol.children].length - 1] ?? null; + const children = this[PropertySymbol.children][PropertySymbol.items]; + return children[children.length - 1] ?? null; } /** @@ -401,7 +404,7 @@ export default class Document extends Node { * @returns Document type. */ public get doctype(): DocumentType { - for (const node of this[PropertySymbol.childNodes]) { + for (const node of this[PropertySymbol.childNodes][PropertySymbol.items]) { if (node instanceof DocumentType) { return node; } @@ -771,7 +774,7 @@ export default class Document extends Node { * @param id ID. * @returns Matching element. */ - public getElementById(id: string): Element { + public getElementById(id: string): Element | null { return ParentNodeUtility.getElementById(this, id); } @@ -787,12 +790,14 @@ export default class Document extends Node { name: string ): NodeList => { const matches = new NodeList(); - for (const child of (parentNode)[PropertySymbol.children]) { + for (const child of (parentNode)[PropertySymbol.children][ + PropertySymbol.items + ]) { if (child.getAttributeNS(null, 'name') === name) { - matches.push(child); + matches[PropertySymbol.addItem](child); } for (const match of getElementsByName(child, name)) { - matches.push(match); + matches[PropertySymbol.addItem](match); } } return matches; @@ -800,47 +805,6 @@ export default class Document extends Node { return getElementsByName(this, name); } - /** - * @override - */ - public override [PropertySymbol.cloneNode](deep = false): Document { - const clone = super[PropertySymbol.cloneNode](deep); - - if (deep) { - for (const node of clone[PropertySymbol.childNodes]) { - if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { - clone[PropertySymbol.children].push(node); - } - } - } - - return clone; - } - - /** - * @override - */ - public override [PropertySymbol.appendChild](node: Node): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.appendChild(this, node); - } - - /** - * @override - */ - public override [PropertySymbol.removeChild](node: Node): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.removeChild(this, node); - } - - /** - * @override - */ - public override [PropertySymbol.insertBefore](newNode: Node, referenceNode: Node | null): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.insertBefore(this, newNode, referenceNode); - } - /** * Replaces the document HTML with new HTML. * @@ -862,7 +826,7 @@ export default class Document extends Node { let documentElement = null; let documentTypeNode = null; - for (const node of root[PropertySymbol.childNodes]) { + for (const node of root[PropertySymbol.childNodes][PropertySymbol.items]) { if (node['tagName'] === 'HTML') { documentElement = node; } else if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentTypeNode) { @@ -897,8 +861,9 @@ export default class Document extends Node { const rootBody = ParentNodeUtility.getElementByTagName(root, 'body'); const body = ParentNodeUtility.getElementByTagName(this, 'body'); if (rootBody && body) { - for (const child of rootBody[PropertySymbol.childNodes].slice()) { - body.appendChild(child); + const childNodes = rootBody[PropertySymbol.childNodes][PropertySymbol.items]; + while (childNodes.length) { + body.appendChild(childNodes[0]); } } } @@ -906,7 +871,9 @@ export default class Document extends Node { // Remaining nodes outside the element are added to the element. const body = ParentNodeUtility.getElementByTagName(this, 'body'); if (body) { - for (const child of root[PropertySymbol.childNodes].slice()) { + const childNodes = root[PropertySymbol.childNodes][PropertySymbol.items]; + while (childNodes.length) { + const child = childNodes[0]; if ( child['tagName'] !== 'HTML' && child[PropertySymbol.nodeType] !== NodeTypeEnum.documentTypeNode @@ -919,9 +886,10 @@ export default class Document extends Node { const documentElement = this.createElement('html'); const bodyElement = this.createElement('body'); const headElement = this.createElement('head'); + const childNodes = root[PropertySymbol.childNodes][PropertySymbol.items]; - for (const child of root[PropertySymbol.childNodes].slice()) { - bodyElement.appendChild(child); + while (childNodes.length) { + bodyElement.appendChild(childNodes[0]); } documentElement.appendChild(headElement); @@ -932,8 +900,11 @@ export default class Document extends Node { } else { const bodyNode = ParentNodeUtility.getElementByTagName(root, 'body'); const body = ParentNodeUtility.getElementByTagName(this, 'body'); - for (const child of ((bodyNode || root))[PropertySymbol.childNodes].slice()) { - body.appendChild(child); + const childNodes = ((bodyNode || root))[PropertySymbol.childNodes][ + PropertySymbol.items + ]; + while (childNodes.length) { + body.appendChild(childNodes[0]); } } } @@ -955,7 +926,7 @@ export default class Document extends Node { } } - for (const child of this[PropertySymbol.childNodes].slice()) { + for (const child of this[PropertySymbol.childNodes][PropertySymbol.items]) { this.removeChild(child); } @@ -1343,7 +1314,7 @@ export default class Document extends Node { #importNode(node: Node): void { node[PropertySymbol.ownerDocument] = this; - for (const child of node[PropertySymbol.childNodes]) { + for (const child of node[PropertySymbol.childNodes][PropertySymbol.items]) { this.#importNode(child); } } diff --git a/packages/happy-dom/src/nodes/element/DatasetFactory.ts b/packages/happy-dom/src/nodes/element/DatasetFactory.ts index 74939fab..1017968b 100644 --- a/packages/happy-dom/src/nodes/element/DatasetFactory.ts +++ b/packages/happy-dom/src/nodes/element/DatasetFactory.ts @@ -1,6 +1,5 @@ import Element from './Element.js'; import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; import DatasetUtility from './DatasetUtility.js'; import IDataset from './IDataset.js'; @@ -47,9 +46,9 @@ export default class DatasetFactory { return true; }, deleteProperty(dataset: IDataset, key: string): boolean { - (element[PropertySymbol.attributes])[ - PropertySymbol.removeNamedItem - ]('data-' + DatasetUtility.camelCaseToKebab(key)); + element[PropertySymbol.attributes][PropertySymbol.removeNamedItem]( + 'data-' + DatasetUtility.camelCaseToKebab(key) + ); return delete dataset[key]; }, ownKeys(dataset: IDataset): string[] { diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 2cb1f56d..21e76789 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -11,17 +11,15 @@ import ParentNodeUtility from '../parent-node/ParentNodeUtility.js'; import NonDocumentChildNodeUtility from '../child-node/NonDocumentChildNodeUtility.js'; import DOMException from '../../exception/DOMException.js'; import HTMLCollection from './HTMLCollection.js'; -import NodeList from '../node/NodeList.js'; +import IHTMLCollection from './IHTMLCollection.js'; import Text from '../text/Text.js'; import DOMRectList from './DOMRectList.js'; import Attr from '../attr/Attr.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; +import NamedNodeMap from './NamedNodeMap.js'; import Event from '../../event/Event.js'; -import ElementUtility from './ElementUtility.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; import CSSStyleDeclaration from '../../css/declaration/CSSStyleDeclaration.js'; import DocumentFragment from '../document-fragment/DocumentFragment.js'; -import ElementNamedNodeMap from './ElementNamedNodeMap.js'; import WindowErrorUtility from '../../window/WindowErrorUtility.js'; import WindowBrowserSettingsReader from '../../window/WindowBrowserSettingsReader.js'; import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; @@ -32,6 +30,10 @@ import ISVGElementTagNameMap from '../../config/ISVGElementTagNameMap.js'; import IChildNode from '../child-node/IChildNode.js'; import INonDocumentTypeChildNode from '../child-node/INonDocumentTypeChildNode.js'; import IParentNode from '../parent-node/IParentNode.js'; +import MutationListener from '../../mutation-observer/MutationListener.js'; +import MutationRecord from '../../mutation-observer/MutationRecord.js'; +import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; +import INodeList from '../node/INodeList.js'; type InsertAdjacentPosition = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'; @@ -88,7 +90,6 @@ export default class Element public ontouchstart: (event: Event) => void | null = null; // Internal properties - public [PropertySymbol.children]: HTMLCollection = new HTMLCollection(); public [PropertySymbol.classList]: DOMTokenList = null; public [PropertySymbol.isValue]: string | null = null; public [PropertySymbol.computedStyle]: CSSStyleDeclaration | null = null; @@ -102,9 +103,39 @@ export default class Element public [PropertySymbol.scrollWidth] = 0; public [PropertySymbol.scrollTop] = 0; public [PropertySymbol.scrollLeft] = 0; - public [PropertySymbol.attributes]: NamedNodeMap = new ElementNamedNodeMap(this); + public [PropertySymbol.attributes] = new NamedNodeMap(this); public [PropertySymbol.namespaceURI]: string | null = this.constructor[PropertySymbol.namespaceURI] || null; + public [PropertySymbol.children]: IHTMLCollection = new HTMLCollection(); + + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + this[PropertySymbol.childNodes][PropertySymbol.addEventListener]('add', (item: Node) => + this[PropertySymbol.children][PropertySymbol.addItem](item) + ); + this[PropertySymbol.childNodes][PropertySymbol.addEventListener]( + 'insert', + (item: Node, referenceItem?: Node) => + this[PropertySymbol.children][PropertySymbol.insertItem]( + item, + referenceItem + ) + ); + this[PropertySymbol.childNodes][PropertySymbol.addEventListener]('remove', (item: Node) => + this[PropertySymbol.children][PropertySymbol.removeItem](item) + ); + } /** * Returns tag name. @@ -209,7 +240,7 @@ export default class Element /** * Returns element children. */ - public get children(): HTMLCollection { + public get children(): IHTMLCollection { return this[PropertySymbol.children]; } @@ -322,7 +353,7 @@ export default class Element */ public get textContent(): string { let result = ''; - for (const childNode of this[PropertySymbol.childNodes]) { + for (const childNode of this[PropertySymbol.childNodes][PropertySymbol.items]) { if ( childNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode || childNode[PropertySymbol.nodeType] === NodeTypeEnum.textNode @@ -339,8 +370,9 @@ export default class Element * @param textContent Text content. */ public set textContent(textContent: string) { - for (const child of this[PropertySymbol.childNodes].slice()) { - this.removeChild(child); + const childNodes = this[PropertySymbol.childNodes][PropertySymbol.items]; + while (childNodes.length) { + this.removeChild(childNodes[0]); } if (textContent) { this.appendChild(this[PropertySymbol.ownerDocument].createTextNode(textContent)); @@ -362,8 +394,10 @@ export default class Element * @param html HTML. */ public set innerHTML(html: string) { - for (const child of this[PropertySymbol.childNodes].slice()) { - this.removeChild(child); + const childNodes = this[PropertySymbol.childNodes][PropertySymbol.items]; + + while (childNodes.length) { + this.removeChild(childNodes[0]); } XMLParser.parse(this[PropertySymbol.ownerDocument], html, { rootNode: this }); @@ -388,21 +422,21 @@ export default class Element } /** - * First element child. + * Last element child. * * @returns Element. */ - public get firstElementChild(): Element { - return this[PropertySymbol.children][0] ?? null; + public get childElementCount(): number { + return this[PropertySymbol.children][PropertySymbol.items].length; } /** - * Last element child. + * First element child. * * @returns Element. */ - public get lastElementChild(): Element { - return this[PropertySymbol.children][this[PropertySymbol.children].length - 1] ?? null; + public get firstElementChild(): Element { + return this[PropertySymbol.children][PropertySymbol.items][0] ?? null; } /** @@ -410,8 +444,9 @@ export default class Element * * @returns Element. */ - public get childElementCount(): number { - return this[PropertySymbol.children].length; + public get lastElementChild(): Element { + const children = this[PropertySymbol.children][PropertySymbol.items]; + return children[children.length - 1] ?? null; } /** @@ -458,7 +493,7 @@ export default class Element escapeEntities: false }); let xml = ''; - for (const node of this[PropertySymbol.childNodes]) { + for (const node of this[PropertySymbol.childNodes][PropertySymbol.items]) { xml += xmlSerializer.serializeToString(node); } return xml; @@ -474,54 +509,9 @@ export default class Element clone[PropertySymbol.localName] = this[PropertySymbol.localName]; clone[PropertySymbol.namespaceURI] = this[PropertySymbol.namespaceURI]; - for (let i = 0, max = this[PropertySymbol.attributes].length; i < max; i++) { - const attribute = this[PropertySymbol.attributes][i]; - clone[PropertySymbol.attributes].setNamedItem( - Object.assign( - this[PropertySymbol.ownerDocument].createAttributeNS( - attribute[PropertySymbol.namespaceURI], - attribute[PropertySymbol.name] - ), - attribute - ) - ); - } - - if (deep) { - for (const node of clone[PropertySymbol.childNodes]) { - if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { - clone[PropertySymbol.children].push(node); - } - } - } - return clone; } - /** - * @override - */ - public override [PropertySymbol.appendChild](node: Node): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.appendChild(this, node); - } - - /** - * @override - */ - public override [PropertySymbol.removeChild](node: Node): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.removeChild(this, node); - } - - /** - * @override - */ - public override [PropertySymbol.insertBefore](newNode: Node, referenceNode: Node | null): Node { - // We do not call super here as this will be handled by ElementUtility to improve performance by avoiding validation and other checks. - return ElementUtility.insertBefore(this, newNode, referenceNode); - } - /** * Removes the node from its parent. */ @@ -619,10 +609,11 @@ export default class Element * @param text HTML string to insert. */ public insertAdjacentHTML(position: InsertAdjacentPosition, text: string): void { - for (const node of (( + const childNodes = (( XMLParser.parse(this[PropertySymbol.ownerDocument], text) - ))[PropertySymbol.childNodes].slice()) { - this.insertAdjacentElement(position, node); + ))[PropertySymbol.childNodes][PropertySymbol.items]; + while (childNodes.length) { + this.insertAdjacentElement(position, childNodes[0]); } } @@ -803,7 +794,7 @@ export default class Element shadowRoot[PropertySymbol.host] = this; shadowRoot[PropertySymbol.mode] = init.mode; - (shadowRoot)[PropertySymbol.connectToNode](this); + (shadowRoot)[PropertySymbol.connectedToDocument](); return this[PropertySymbol.shadowRoot]; } @@ -878,7 +869,7 @@ export default class Element */ public querySelectorAll( selector: K - ): NodeList; + ): INodeList; /** * Query CSS selector to find matching elments. @@ -888,7 +879,7 @@ export default class Element */ public querySelectorAll( selector: K - ): NodeList; + ): INodeList; /** * Query CSS selector to find matching elments. @@ -896,7 +887,7 @@ export default class Element * @param selector CSS selector. * @returns Matching elements. */ - public querySelectorAll(selector: string): NodeList; + public querySelectorAll(selector: string): INodeList; /** * Query CSS selector to find matching elments. @@ -904,7 +895,7 @@ export default class Element * @param selector CSS selector. * @returns Matching elements. */ - public querySelectorAll(selector: string): NodeList { + public querySelectorAll(selector: string): INodeList { return QuerySelector.querySelectorAll(this, selector); } @@ -952,7 +943,7 @@ export default class Element * @param className Tag name. * @returns Matching element. */ - public getElementsByClassName(className: string): HTMLCollection { + public getElementsByClassName(className: string): IHTMLCollection { return ParentNodeUtility.getElementsByClassName(this, className); } @@ -964,7 +955,7 @@ export default class Element */ public getElementsByTagName( tagName: K - ): HTMLCollection; + ): IHTMLCollection; /** * Returns an elements by tag name. @@ -974,7 +965,7 @@ export default class Element */ public getElementsByTagName( tagName: K - ): HTMLCollection; + ): IHTMLCollection; /** * Returns an elements by tag name. @@ -982,7 +973,7 @@ export default class Element * @param tagName Tag name. * @returns Matching element. */ - public getElementsByTagName(tagName: string): HTMLCollection; + public getElementsByTagName(tagName: string): IHTMLCollection; /** * Returns an elements by tag name. @@ -990,7 +981,7 @@ export default class Element * @param tagName Tag name. * @returns Matching element. */ - public getElementsByTagName(tagName: string): HTMLCollection { + public getElementsByTagName(tagName: string): IHTMLCollection { return ParentNodeUtility.getElementsByTagName(this, tagName); } @@ -1004,7 +995,7 @@ export default class Element public getElementsByTagNameNS( namespaceURI: 'http://www.w3.org/1999/xhtml', tagName: K - ): HTMLCollection; + ): IHTMLCollection; /** * Returns an elements by tag name and namespace. @@ -1016,7 +1007,7 @@ export default class Element public getElementsByTagNameNS( namespaceURI: 'http://www.w3.org/2000/svg', tagName: K - ): HTMLCollection; + ): IHTMLCollection; /** * Returns an elements by tag name and namespace. @@ -1025,7 +1016,7 @@ export default class Element * @param tagName Tag name. * @returns Matching element. */ - public getElementsByTagNameNS(namespaceURI: string, tagName: string): HTMLCollection; + public getElementsByTagNameNS(namespaceURI: string, tagName: string): IHTMLCollection; /** * Returns an elements by tag name and namespace. @@ -1034,7 +1025,7 @@ export default class Element * @param tagName Tag name. * @returns Matching element. */ - public getElementsByTagNameNS(namespaceURI: string, tagName: string): HTMLCollection { + public getElementsByTagNameNS(namespaceURI: string, tagName: string): IHTMLCollection { return ParentNodeUtility.getElementsByTagNameNS(this, namespaceURI, tagName); } @@ -1202,4 +1193,134 @@ export default class Element return returnValue; } + + /** + * Triggered when an attribute is set. + * + * @param attribute Attribute. + * @param replacedAttribute Replaced attribute. + */ + #onSetAttribute(attribute: Attr, replacedAttribute: Attr | null): void { + if (!attribute[PropertySymbol.name]) { + return null; + } + + if (this[PropertySymbol.isConnected]) { + this.ownerDocument[PropertySymbol.cacheID]++; + } + + const oldValue = replacedAttribute ? replacedAttribute[PropertySymbol.value] : null; + + if (attribute[PropertySymbol.name] === 'class' && this[PropertySymbol.classList]) { + this[PropertySymbol.classList][PropertySymbol.updateIndices](); + } + + if (attribute[PropertySymbol.name] === 'id' || attribute[PropertySymbol.name] === 'name') { + const parent = this[PropertySymbol.parentNode]; + while (parent) { + this[PropertySymbol.parentNode][PropertySymbol.childNodes][ + PropertySymbol.htmlCollections + ].updateNamedItem(this, attribute, replacedAttribute); + let parent = this[PropertySymbol.parentNode]; + while (parent) { + parent[PropertySymbol.childNodesFlatten][PropertySymbol.htmlCollections].updateNamedItem( + this, + attribute, + replacedAttribute + ); + parent = parent[PropertySymbol.parentNode]; + } + } + } + + if ( + this.attributeChangedCallback && + (this.constructor)[PropertySymbol.observedAttributes] && + (this.constructor)[PropertySymbol.observedAttributes].includes( + attribute[PropertySymbol.name] + ) + ) { + this.attributeChangedCallback( + attribute[PropertySymbol.name], + oldValue, + attribute[PropertySymbol.value] + ); + } + + // MutationObserver + if (this[PropertySymbol.observers].length > 0) { + for (const observer of this[PropertySymbol.observers]) { + if ( + observer.options?.attributes && + (!observer.options.attributeFilter || + observer.options.attributeFilter.includes(attribute[PropertySymbol.name])) + ) { + observer.report( + new MutationRecord({ + target: this, + type: MutationTypeEnum.attributes, + attributeName: attribute[PropertySymbol.name], + oldValue: observer.options.attributeOldValue ? oldValue : null + }) + ); + } + } + } + } + + /** + * Triggered when an attribute is set. + * + * @param removedAttribute Attribute. + */ + #onRemoveAttribute(removedAttribute: Attr): void { + if (this[PropertySymbol.isConnected]) { + this.ownerDocument[PropertySymbol.cacheID]++; + } + + if (removedAttribute[PropertySymbol.name] === 'class' && this[PropertySymbol.classList]) { + this[PropertySymbol.classList][PropertySymbol.updateIndices](); + } + + if ( + this[PropertySymbol.parentNode] && + (removedAttribute[PropertySymbol.name] === 'id' || + removedAttribute[PropertySymbol.name] === 'name') + ) { + this[PropertySymbol.parentNode][PropertySymbol.childNodes][ + PropertySymbol.htmlCollections + ].updateNamedItem(this, null, removedAttribute); + let parent = this[PropertySymbol.parentNode]; + while (parent) { + parent[PropertySymbol.childNodesFlatten][PropertySymbol.htmlCollections].updateNamedItem( + this, + null, + removedAttribute + ); + parent = parent[PropertySymbol.parentNode]; + } + } + + // MutationObserver + if (this[PropertySymbol.observers].length > 0) { + for (const observer of this[PropertySymbol.observers]) { + if ( + observer.options?.attributes && + (!observer.options.attributeFilter || + observer.options.attributeFilter.includes(removedAttribute[PropertySymbol.name])) + ) { + observer.report( + new MutationRecord({ + target: this, + type: MutationTypeEnum.attributes, + attributeName: removedAttribute[PropertySymbol.name], + oldValue: observer.options.attributeOldValue + ? removedAttribute[PropertySymbol.value] + : null + }) + ); + } + } + } + } } diff --git a/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts deleted file mode 100644 index d21b0235..00000000 --- a/packages/happy-dom/src/nodes/element/ElementNamedNodeMap.ts +++ /dev/null @@ -1,241 +0,0 @@ -import NamespaceURI from '../../config/NamespaceURI.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import MutationRecord from '../../mutation-observer/MutationRecord.js'; -import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import Attr from '../attr/Attr.js'; -import Element from './Element.js'; -import HTMLCollection from './HTMLCollection.js'; -import MutationListener from '../../mutation-observer/MutationListener.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class ElementNamedNodeMap extends NamedNodeMap { - protected [PropertySymbol.ownerElement]: Element; - - /** - * Constructor. - * - * @param ownerElement Owner element. - */ - constructor(ownerElement: Element) { - super(); - this[PropertySymbol.ownerElement] = ownerElement; - } - - /** - * @override - */ - public override getNamedItem(name: string): Attr | null { - return this[PropertySymbol.namedItems][this[PropertySymbol.getAttributeName](name)] || null; - } - - /** - * @override - */ - public override getNamedItemNS(namespace: string, localName: string): Attr | null { - return super.getNamedItemNS(namespace, this[PropertySymbol.getAttributeName](localName)); - } - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - if (!item[PropertySymbol.name]) { - return null; - } - - item[PropertySymbol.name] = this[PropertySymbol.getAttributeName](item[PropertySymbol.name]); - (item[PropertySymbol.ownerElement]) = this[PropertySymbol.ownerElement]; - - const replacedItem = super[PropertySymbol.setNamedItem](item); - const oldValue = replacedItem ? replacedItem[PropertySymbol.value] : null; - - if (this[PropertySymbol.ownerElement][PropertySymbol.isConnected]) { - this[PropertySymbol.ownerElement].ownerDocument[PropertySymbol.cacheID]++; - } - - if ( - item[PropertySymbol.name] === 'class' && - this[PropertySymbol.ownerElement][PropertySymbol.classList] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.classList][PropertySymbol.updateIndices](); - } - - if (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') { - if ( - this[PropertySymbol.ownerElement][PropertySymbol.parentNode] && - (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ - PropertySymbol.children - ] && - item[PropertySymbol.value] !== oldValue - ) { - if (oldValue) { - (>( - (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ - PropertySymbol.children - ] - ))[PropertySymbol.removeNamedItem](this[PropertySymbol.ownerElement], oldValue); - } - if (item[PropertySymbol.value]) { - (>( - (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ - PropertySymbol.children - ] - ))[PropertySymbol.appendNamedItem]( - this[PropertySymbol.ownerElement], - item[PropertySymbol.value] - ); - } - } - } - - if ( - this[PropertySymbol.ownerElement].attributeChangedCallback && - (this[PropertySymbol.ownerElement].constructor)[ - PropertySymbol.observedAttributes - ] && - (this[PropertySymbol.ownerElement].constructor)[ - PropertySymbol.observedAttributes - ].includes(item[PropertySymbol.name]) - ) { - this[PropertySymbol.ownerElement].attributeChangedCallback( - item[PropertySymbol.name], - oldValue, - item[PropertySymbol.value] - ); - } - - // MutationObserver - if (this[PropertySymbol.ownerElement][PropertySymbol.observers].length > 0) { - for (const observer of ( - this[PropertySymbol.ownerElement][PropertySymbol.observers] - )) { - if ( - observer.options?.attributes && - (!observer.options.attributeFilter || - observer.options.attributeFilter.includes(item[PropertySymbol.name])) - ) { - observer.report( - new MutationRecord({ - target: this[PropertySymbol.ownerElement], - type: MutationTypeEnum.attributes, - attributeName: item[PropertySymbol.name], - oldValue: observer.options.attributeOldValue ? oldValue : null - }) - ); - } - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem]( - this[PropertySymbol.getAttributeName](name) - ); - - if (!removedItem) { - return null; - } - - if (this[PropertySymbol.ownerElement][PropertySymbol.isConnected]) { - this[PropertySymbol.ownerElement].ownerDocument[PropertySymbol.cacheID]++; - } - - if ( - removedItem[PropertySymbol.name] === 'class' && - this[PropertySymbol.ownerElement][PropertySymbol.classList] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.classList][PropertySymbol.updateIndices](); - } - - if (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') { - if ( - this[PropertySymbol.ownerElement][PropertySymbol.parentNode] && - (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ - PropertySymbol.children - ] && - removedItem[PropertySymbol.value] - ) { - (>( - (this[PropertySymbol.ownerElement][PropertySymbol.parentNode])[ - PropertySymbol.children - ] - ))[PropertySymbol.removeNamedItem]( - this[PropertySymbol.ownerElement], - removedItem[PropertySymbol.value] - ); - } - } - - if ( - this[PropertySymbol.ownerElement].attributeChangedCallback && - (this[PropertySymbol.ownerElement].constructor)[ - PropertySymbol.observedAttributes - ] && - (this[PropertySymbol.ownerElement].constructor)[ - PropertySymbol.observedAttributes - ].includes(removedItem[PropertySymbol.name]) - ) { - this[PropertySymbol.ownerElement].attributeChangedCallback( - removedItem[PropertySymbol.name], - removedItem[PropertySymbol.value], - null - ); - } - - // MutationObserver - if (this[PropertySymbol.ownerElement][PropertySymbol.observers].length > 0) { - for (const observer of ( - this[PropertySymbol.ownerElement][PropertySymbol.observers] - )) { - if ( - observer.options?.attributes && - (!observer.options.attributeFilter || - observer.options.attributeFilter.includes(removedItem[PropertySymbol.name])) - ) { - observer.report( - new MutationRecord({ - target: this[PropertySymbol.ownerElement], - type: MutationTypeEnum.attributes, - attributeName: removedItem[PropertySymbol.name], - oldValue: observer.options.attributeOldValue - ? removedItem[PropertySymbol.value] - : null - }) - ); - } - } - } - - return removedItem; - } - - /** - * @override - */ - public override removeNamedItemNS(namespace: string, localName: string): Attr | null { - return super.removeNamedItemNS(namespace, this[PropertySymbol.getAttributeName](localName)); - } - - /** - * Returns attribute name. - * - * @param name Name. - * @returns Attribute name based on namespace. - */ - protected [PropertySymbol.getAttributeName](name): string { - if (this[PropertySymbol.ownerElement][PropertySymbol.namespaceURI] === NamespaceURI.svg) { - return name; - } - return name.toLowerCase(); - } -} diff --git a/packages/happy-dom/src/nodes/element/ElementUtility.ts b/packages/happy-dom/src/nodes/element/ElementUtility.ts deleted file mode 100644 index cc5d861e..00000000 --- a/packages/happy-dom/src/nodes/element/ElementUtility.ts +++ /dev/null @@ -1,217 +0,0 @@ -import NodeTypeEnum from '../node/NodeTypeEnum.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import Element from './Element.js'; -import Node from '../node/Node.js'; -import HTMLCollection from './HTMLCollection.js'; -import Document from '../document/Document.js'; -import DocumentFragment from '../document-fragment/DocumentFragment.js'; -import HTMLElement from '../html-element/HTMLElement.js'; -import NodeUtility from '../node/NodeUtility.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; - -const NAMED_ITEM_ATTRIBUTES = ['id', 'name']; - -/** - * Element utility. - */ -export default class ElementUtility { - /** - * Handles appending a child element to the "children" property. - * - * @param ancestorNode Ancestor node. - * @param node Node to append. - * @param [options] Options. - * @param [options.disableAncestorValidation] Disables validation for checking if the node is an ancestor of the ancestorNode. - * @returns Appended node. - */ - public static appendChild( - ancestorNode: Element | Document | DocumentFragment, - node: Node, - options?: { disableAncestorValidation?: boolean } - ): Node { - if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode && node !== ancestorNode) { - if ( - !options?.disableAncestorValidation && - NodeUtility.isInclusiveAncestor(node, ancestorNode) - ) { - throw new DOMException( - "Failed to execute 'appendChild' on 'Node': The new node is a parent of the node to insert to.", - DOMExceptionNameEnum.domException - ); - } - if (node[PropertySymbol.parentNode]) { - const parentNodeChildren = >( - (node[PropertySymbol.parentNode])[PropertySymbol.children] - ); - - if (parentNodeChildren) { - const index = parentNodeChildren.indexOf(node); - if (index !== -1) { - for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (node)[PropertySymbol.attributes].getNamedItem( - attributeName - ); - if (attribute) { - parentNodeChildren[PropertySymbol.removeNamedItem]( - node, - attribute[PropertySymbol.value] - ); - } - } - - parentNodeChildren.splice(index, 1); - } - } - } - const ancestorNodeChildren = >( - (ancestorNode)[PropertySymbol.children] - ); - - for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (node)[PropertySymbol.attributes].getNamedItem(attributeName); - if (attribute) { - ancestorNodeChildren[PropertySymbol.appendNamedItem]( - node, - attribute[PropertySymbol.value] - ); - } - } - - ancestorNodeChildren.push(node); - - NodeUtility.appendChild(ancestorNode, node, { disableAncestorValidation: true }); - } else { - NodeUtility.appendChild(ancestorNode, node, options); - } - - return node; - } - - /** - * Handles removing a child element from the "children" property. - * - * @param ancestorNode Ancestor node. - * @param node Node. - * @returns Removed node. - */ - public static removeChild(ancestorNode: Element | Document | DocumentFragment, node: Node): Node { - if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { - const ancestorNodeChildren = >( - (ancestorNode)[PropertySymbol.children] - ); - const index = ancestorNodeChildren.indexOf(node); - if (index !== -1) { - for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (node)[PropertySymbol.attributes].getNamedItem(attributeName); - if (attribute) { - ancestorNodeChildren[PropertySymbol.removeNamedItem]( - node, - attribute[PropertySymbol.value] - ); - } - } - ancestorNodeChildren.splice(index, 1); - } - } - - NodeUtility.removeChild(ancestorNode, node); - - return node; - } - - /** - * - * Handles inserting a child element to the "children" property. - * - * @param ancestorNode Ancestor node. - * @param newNode Node to insert. - * @param referenceNode Node to insert before. - * @param [options] Options. - * @param [options.disableAncestorValidation] Disables validation for checking if the node is an ancestor of the ancestorNode. - * @returns Inserted node. - */ - public static insertBefore( - ancestorNode: Element | Document | DocumentFragment, - newNode: Node, - referenceNode: Node | null, - options?: { disableAncestorValidation?: boolean } - ): Node { - // NodeUtility.insertBefore() will call appendChild() for the scenario where "referenceNode" is "null" or "undefined" - if (newNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode && referenceNode) { - if ( - !options?.disableAncestorValidation && - NodeUtility.isInclusiveAncestor(newNode, ancestorNode) - ) { - throw new DOMException( - "Failed to execute 'insertBefore' on 'Node': The new node is a parent of the node to insert to.", - DOMExceptionNameEnum.domException - ); - } - if (newNode[PropertySymbol.parentNode]) { - const parentNodeChildren = >( - (newNode[PropertySymbol.parentNode])[PropertySymbol.children] - ); - - if (parentNodeChildren) { - const index = parentNodeChildren.indexOf(newNode); - if (index !== -1) { - for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (newNode)[PropertySymbol.attributes].getNamedItem( - attributeName - ); - if (attribute) { - parentNodeChildren[PropertySymbol.removeNamedItem]( - newNode, - attribute[PropertySymbol.value] - ); - } - } - - parentNodeChildren.splice(index, 1); - } - } - } - - const ancestorNodeChildren = >( - (ancestorNode)[PropertySymbol.children] - ); - - if (referenceNode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { - const index = ancestorNodeChildren.indexOf(referenceNode); - if (index !== -1) { - ancestorNodeChildren.splice(index, 0, newNode); - } - } else { - ancestorNodeChildren.length = 0; - - for (const node of (ancestorNode)[PropertySymbol.childNodes]) { - if (node === referenceNode) { - ancestorNodeChildren.push(newNode); - } - if (node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { - ancestorNodeChildren.push(node); - } - } - } - - for (const attributeName of NAMED_ITEM_ATTRIBUTES) { - const attribute = (newNode)[PropertySymbol.attributes].getNamedItem(attributeName); - if (attribute) { - ancestorNodeChildren[PropertySymbol.appendNamedItem]( - newNode, - attribute[PropertySymbol.value] - ); - } - } - - NodeUtility.insertBefore(ancestorNode, newNode, referenceNode, { - disableAncestorValidation: true - }); - } else { - NodeUtility.insertBefore(ancestorNode, newNode, referenceNode, options); - } - - return newNode; - } -} diff --git a/packages/happy-dom/src/nodes/element/HTMLCollection.ts b/packages/happy-dom/src/nodes/element/HTMLCollection.ts index 7c364376..97ec54df 100644 --- a/packages/happy-dom/src/nodes/element/HTMLCollection.ts +++ b/packages/happy-dom/src/nodes/element/HTMLCollection.ts @@ -1,17 +1,91 @@ import * as PropertySymbol from '../../PropertySymbol.js'; +import Attr from '../attr/Attr.js'; +import Node from '../node/Node.js'; +import NodeTypeEnum from '../node/NodeTypeEnum.js'; +import Element from './Element.js'; +import IHTMLCollection from './IHTMLCollection.js'; +import THTMLCollectionListener from './THTMLCollectionListener.js'; +import TNamedNodeMapListener from './TNamedNodeMapListener.js'; + +const NAMED_ITEM_ATTRIBUTES = ['id', 'name']; /** - * HTML collection. + * HTMLCollection. + * + * We are extending Array here to improve performance. + * However, we should not expose Array methods to the outside. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLCollection */ -export default class HTMLCollection extends Array implements HTMLCollection { - protected [PropertySymbol.namedItems]: { [k: string]: T[] } = {}; +class HTMLCollection extends Array implements IHTMLCollection { + public [PropertySymbol.namedItems] = new Map>(); + #namedNodeMapListeners = new Map< + T, + { set: TNamedNodeMapListener; remove: TNamedNodeMapListener } + >(); + #eventListeners: { + indexChange: WeakRef>[]; + propertyChange: WeakRef>[]; + } = { + indexChange: [], + propertyChange: [] + }; + #filter: (item: T) => boolean | null; + + /** + * Constructor. + * + * @param [filter] Filter. + * @param items + */ + constructor( + filter?: (item: T) => boolean, + items?: Array<{ [index: number]: T; length: number }> + ) { + super(); + + this.#filter = filter || null; + + if (items) { + for (let i = 0, max = items.length; i < max; i++) { + this[PropertySymbol.addItem](items[i]); + } + } + } + + /** + * Returns `Symbol.toStringTag`. + * + * @returns `Symbol.toStringTag`. + */ + public get [Symbol.toStringTag](): string { + return this.constructor.name; + } + + /** + * Returns `[object HTMLCollection]`. + * + * @returns `[object HTMLCollection]`. + */ + public toLocaleString(): string { + return `[object ${this.constructor.name}]`; + } + + /** + * Returns `[object HTMLCollection]`. + * + * @returns `[object HTMLCollection]`. + */ + public toString(): string { + return `[object ${this.constructor.name}]`; + } /** * Returns item by index. * * @param index Index. */ - public item(index: number): T | null { + public item(index: number): T { return index >= 0 && this[index] ? this[index] : null; } @@ -21,57 +95,344 @@ export default class HTMLCollection extends Array implements HTMLCollectionreferenceItem).parentNode?.[PropertySymbol.childNodes]; + let referenceItemIndex: number = -1; + + if (referenceItem[PropertySymbol.nodeType] === NodeTypeEnum.elementNode) { + referenceItemIndex = parentChildNodes[PropertySymbol.indexOf](referenceItem); + } else { + for ( + let i = parentChildNodes[PropertySymbol.indexOf](referenceItem), + max = parentChildNodes.length; + i < max; + i++ + ) { + if ( + parentChildNodes[i][PropertySymbol.nodeType] === NodeTypeEnum.elementNode && + (!filter || filter(parentChildNodes[i])) + ) { + referenceItemIndex = i; + break; + } + } + } + + if (referenceItemIndex === -1) { + return this[PropertySymbol.addItem](newItem); + } + + super.splice(referenceItemIndex, 0, newItem); + + this[PropertySymbol.addNamedItem](newItem); + this[PropertySymbol.dispatchEvent]('indexChange', { index: referenceItemIndex, item: newItem }); + + return true; + } + + /** + * Removes item. + * + * @param item Item. + * @returns True if removed. + */ + public [PropertySymbol.removeItem](item: T): boolean { + const index = super.indexOf(item); + + if (index === -1) { + return false; + } + + super.splice(index, 1); + + this[PropertySymbol.removeNamedItem](item); + + return true; + } + + /** + * Index of item. + * + * @param item Item. + * @returns Index. + */ + public [PropertySymbol.indexOf](item: T): number { + return super.indexOf(item); + } + + /** + * Returns true if the item is in the list. + * + * @param item Item. + * @returns True if the item is in the list. */ - public [PropertySymbol.appendNamedItem](node: T, name: string): void { + public [PropertySymbol.includes](item: T): boolean { + return super.includes(item); + } + + /** + * Adds event listener. + * + * @param type Type. + * @param listener Listener. + */ + public [PropertySymbol.addEventListener]( + type: 'indexChange' | 'propertyChange', + listener: THTMLCollectionListener + ): void { + this.#eventListeners[type].push(new WeakRef(listener)); + } + + /** + * Removes event listener. + * + * @param type Type. + * @param listener Listener. + */ + public [PropertySymbol.removeEventListener]( + type: 'indexChange' | 'propertyChange', + listener: THTMLCollectionListener + ): void { + const listeners = this.#eventListeners[type]; + for (let i = 0, max = listeners.length; i < max; i++) { + if (listeners[i].deref() === listener) { + listeners.splice(i, 1); + return; + } + } + } + + /** + * Dispatches event. + * + * @param type Type. + * @param details Options. + * @param [details.index] Index. + * @param [details.item] Item. + * @param [details.propertyName] Property name. + * @param [details.propertyValue] Property value. + */ + public [PropertySymbol.dispatchEvent]( + type: 'indexChange' | 'propertyChange', + details: { + index?: number; + item?: T; + propertyName?: string; + propertyValue?: any; + } + ): void { + const listeners = this.#eventListeners[type]; + for (let i = 0, max = listeners.length; i < max; i++) { + const listener = listeners[i].deref(); + if (listener) { + listener(details); + } else { + listeners.splice(i, 1); + i--; + max--; + } + } + } + + /** + * Updates named item. + * + * @param item Item. + * @param attributeName Attribute name. + */ + public [PropertySymbol.updateNamedItem](item: T, attributeName: string): void { + if (!this.#filter(item)) { + return; + } + + const name = (item)[PropertySymbol.attributes][attributeName]?.value; + if (name) { - this[PropertySymbol.namedItems][name] = this[PropertySymbol.namedItems][name] || []; + const namedItems = this[PropertySymbol.getNamedItems](name); - if (!this[PropertySymbol.namedItems][name].includes(node)) { - this[PropertySymbol.namedItems][name].push(node); + if (!namedItems.includes(item)) { + this[PropertySymbol.namedItems].set(name, namedItems); + this[PropertySymbol.setNamedItemProperty](name); } + } else { + const namedItems = this[PropertySymbol.getNamedItems](name); + const index = namedItems.indexOf(item); - if (!this.hasOwnProperty(name) && this[PropertySymbol.isValidPropertyName](name)) { - this[name] = this[PropertySymbol.namedItems][name][0]; + if (index !== -1) { + namedItems.splice(index, 1); } + + this[PropertySymbol.setNamedItemProperty](name); } } /** - * Appends named item. + * Adds named item to collection. * - * @param node Node. - * @param name Name. + * @param item Item. + */ + protected [PropertySymbol.addNamedItem](item: T): void { + const listeners = { + set: (attribute: Attr) => { + if (NAMED_ITEM_ATTRIBUTES.includes(attribute.name)) { + this[PropertySymbol.updateNamedItem](item, attribute.name); + } + }, + remove: (attribute: Attr) => { + if (NAMED_ITEM_ATTRIBUTES.includes(attribute.name)) { + this[PropertySymbol.updateNamedItem](item, attribute.name); + } + } + }; + + item[PropertySymbol.attributes][PropertySymbol.addEventListener]('set', listeners.set); + item[PropertySymbol.attributes][PropertySymbol.addEventListener]('remove', listeners.remove); + + for (const attributeName of NAMED_ITEM_ATTRIBUTES) { + const name = (item)[PropertySymbol.attributes][attributeName]?.value; + if (name) { + const namedItems = this[PropertySymbol.getNamedItems](name); + + if (namedItems.includes(item)) { + return; + } + + this[PropertySymbol.namedItems].set(name, namedItems); + + this[PropertySymbol.setNamedItemProperty](name); + } + } + } + + /** + * Removes named item from collection. + * + * @param item Item. */ - public [PropertySymbol.removeNamedItem](node: T, name: string): void { - if (name && this[PropertySymbol.namedItems][name]) { - const index = this[PropertySymbol.namedItems][name].indexOf(node); + protected [PropertySymbol.removeNamedItem](item: T): void { + const listeners = this.#namedNodeMapListeners.get(item); - if (index > -1) { - this[PropertySymbol.namedItems][name].splice(index, 1); + if (listeners) { + item[PropertySymbol.attributes][PropertySymbol.removeEventListener]('set', listeners.set); + item[PropertySymbol.attributes][PropertySymbol.removeEventListener]( + 'remove', + listeners.remove + ); + } - if (this[PropertySymbol.namedItems][name].length === 0) { - delete this[PropertySymbol.namedItems][name]; - if (this.hasOwnProperty(name) && this[PropertySymbol.isValidPropertyName](name)) { - delete this[name]; - } - } else if (this[PropertySymbol.isValidPropertyName](name)) { - this[name] = this[PropertySymbol.namedItems][name][0]; + for (const attributeName of NAMED_ITEM_ATTRIBUTES) { + const name = (item)[PropertySymbol.attributes][attributeName]?.value; + if (name) { + const namedItems = this[PropertySymbol.getNamedItems](name); + + const index = namedItems.indexOf(item); + + if (index === -1) { + return; } + + namedItems.splice(index, 1); + + this[PropertySymbol.setNamedItemProperty](name); } } } + /** + * Returns named items. + * + * @param name Name. + * @returns Named items. + */ + protected [PropertySymbol.getNamedItems](name: string): T[] { + return this[PropertySymbol.namedItems].get(name) || []; + } + + /** + * Sets named item property. + * + * @param name Name. + */ + protected [PropertySymbol.setNamedItemProperty](name: string): void { + if (!this[PropertySymbol.isValidPropertyName](name)) { + return; + } + + const namedItems = this[PropertySymbol.namedItems].get(name); + + if (namedItems?.length) { + if (Object.getOwnPropertyDescriptor(this, name)?.value !== namedItems[0]) { + Object.defineProperty(this, name, { + value: namedItems[0], + writable: false, + enumerable: true, + configurable: true + }); + } + } else { + delete this[name]; + } + + this[PropertySymbol.dispatchEvent]('propertyChange', { + propertyName: name, + propertyValue: this[name] ?? null + }); + } + /** * Returns "true" if the property name is valid. * @@ -82,8 +443,28 @@ export default class HTMLCollection extends Array implements HTMLCollection {}, + get: descriptor.get + }); + } else { + if (typeof descriptor.value === 'function') { + Object.defineProperty(HTMLCollection.prototype, key, {}); + } + } +} + +// Forces the type to be an interface to hide Array methods from the outside. +export default < + new (filter?: (item: T) => boolean) => IHTMLCollection +>(HTMLCollection); diff --git a/packages/happy-dom/src/nodes/element/IHTMLCollection.ts b/packages/happy-dom/src/nodes/element/IHTMLCollection.ts new file mode 100644 index 00000000..fdc7bfe6 --- /dev/null +++ b/packages/happy-dom/src/nodes/element/IHTMLCollection.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable filenames/match-exported */ + +import * as PropertySymbol from '../../PropertySymbol.js'; +import THTMLCollectionListener from './THTMLCollectionListener.js'; + +/** + * HTMLCollection. + * + * This interface is used to hide Array methods from the outside. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLCollection + */ +export default interface IHTMLCollection { + [index: number]: T; + + /** + * Returns the number of items in the collection. + * + * @returns Number of items. + */ + readonly length: number; + + /** + * Returns item by index. + * + * @param index Index. + * @returns Item. + */ + item(index: number): T | null; + + /** + * Returns item by name. + * + * @param name Name. + * @returns Item. + */ + namedItem(name: string): NamedItem | null; + + /** + * Appends item. + * + * @param item Item. + * @returns True if added. + */ + [PropertySymbol.addItem](item: T): boolean; + + /** + * Inserts item before another item. + * + * @param newItem New item. + * @param [referenceItem] Reference item. + * @returns True if inserted. + */ + [PropertySymbol.insertItem](newItem: T, referenceItem: T | null): boolean; + + /** + * Removes item. + * + * @param item Item. + * @returns True if removed. + */ + [PropertySymbol.removeItem](item: T): boolean; + + /** + * Adds event listener. + * + * @param type Type. + * @param listener Listener. + */ + [PropertySymbol.addEventListener]( + type: 'indexChange' | 'propertyChange', + listener: THTMLCollectionListener + ): void; + + /** + * Removes event listener. + * + * @param type Type. + * @param listener Listener. + */ + [PropertySymbol.removeEventListener]( + type: 'indexChange' | 'propertyChange', + listener: THTMLCollectionListener + ): void; + + /** + * Dispatches event. + * + * @param type Type. + * @param details Options. + * @param [details.index] Index. + * @param [details.item] Item. + * @param [details.propertyName] Property name. + * @param [details.propertyValue] Property value. + */ + [PropertySymbol.dispatchEvent]( + type: 'indexChange' | 'propertyChange', + details: { + index?: number; + item?: T; + propertyName?: string; + propertyValue?: any; + } + ): void; + + /** + * Updates named item. + * + * @param item Item. + * @param attributeName Attribute name. + */ + [PropertySymbol.updateNamedItem](item: T, attributeName: string): void; + + /** + * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object. + * + * @returns Iterator. + */ + [Symbol.iterator](): IterableIterator; + + /** + * Index of item. + * + * @param item Item. + * @returns Index. + */ + [PropertySymbol.indexOf](item?: T): number; + + /** + * Returns true if the item is in the list. + * + * @param item Item. + * @returns True if the item is in the list. + */ + [PropertySymbol.includes](item: T): boolean; +} diff --git a/packages/happy-dom/src/nodes/element/NamedNodeMap.ts b/packages/happy-dom/src/nodes/element/NamedNodeMap.ts new file mode 100644 index 00000000..5c247f5f --- /dev/null +++ b/packages/happy-dom/src/nodes/element/NamedNodeMap.ts @@ -0,0 +1,333 @@ +import * as PropertySymbol from '../../PropertySymbol.js'; +import Attr from '../attr/Attr.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import Element from './Element.js'; +import NamespaceURI from '../../config/NamespaceURI.js'; +import TNamedNodeMapListener from './TNamedNodeMapListener.js'; + +/** + * Named Node Map. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap + */ +export default class NamedNodeMap { + [index: number]: Attr; + + public length = 0; + public [PropertySymbol.namedItems]: Map = new Map(); + public [PropertySymbol.ownerElement]: Element; + + #eventListeners: { + set: WeakRef[]; + remove: WeakRef[]; + } = { + set: [], + remove: [] + }; + + /** + * Constructor. + * + * @param ownerElement Owner element. + */ + constructor(ownerElement: Element) { + this[PropertySymbol.ownerElement] = ownerElement; + } + + /** + * Returns string. + * + * @returns string. + */ + public get [Symbol.toStringTag](): string { + return 'NamedNodeMap'; + } + + /** + * Iterator. + * + * @returns Iterator. + */ + public *[Symbol.iterator](): IterableIterator { + for (let i = 0, max = this.length; i < max; i++) { + yield this[i]; + } + } + + /** + * Returns item by index. + * + * @param index Index. + */ + public item(index: number): Attr | null { + return index >= 0 && this[index] ? this[index] : null; + } + + /** + * Returns named item. + * + * @param name Name. + * @returns Item. + */ + public getNamedItem(name: string): Attr | null { + return this[PropertySymbol.namedItems].get(this.#getAttributeName(name)) || null; + } + + /** + * Returns item by name and namespace. + * + * @param namespace Namespace. + * @param localName Local name of the attribute. + * @returns Item. + */ + public getNamedItemNS(namespace: string, localName: string): Attr | null { + const attribute = this.getNamedItem(localName); + + if ( + attribute && + attribute[PropertySymbol.namespaceURI] === namespace && + attribute.localName === localName + ) { + return attribute; + } + + for (let i = 0, max = this.length; i < max; i++) { + if (this[i][PropertySymbol.namespaceURI] === namespace && this[i].localName === localName) { + return this[i]; + } + } + + return null; + } + + /** + * Sets named item. + * + * @param item Item. + * @returns Replaced item. + */ + public setNamedItem(item: Attr): Attr | null { + return this[PropertySymbol.setNamedItem](item); + } + + /** + * Adds a new namespaced item. + * + * @alias setNamedItem() + * @param item Item. + * @returns Replaced item. + */ + public setNamedItemNS(item: Attr): Attr | null { + return this[PropertySymbol.setNamedItem](item); + } + + /** + * Removes an item. + * + * @throws DOMException + * @param name Name of item. + * @returns Removed item. + */ + public removeNamedItem(name: string): Attr { + const item = this[PropertySymbol.removeNamedItem](name); + if (!item) { + throw new DOMException( + `Failed to execute 'removeNamedItem' on 'NamedNodeMap': No item with name '${name}' was found.`, + DOMExceptionNameEnum.notFoundError + ); + } + return item; + } + + /** + * Removes a namespaced item. + * + * @param namespace Namespace. + * @param localName Local name of the item. + * @returns Removed item. + */ + public removeNamedItemNS(namespace: string, localName: string): Attr | null { + const attribute = this.getNamedItemNS(namespace, this.#getAttributeName(localName)); + if (attribute) { + return this.removeNamedItem(attribute[PropertySymbol.name]); + } + return null; + } + + /** + * Adds event listener. + * + * @param type Type. + * @param listener Listener. + */ + public [PropertySymbol.addEventListener]( + type: 'set' | 'remove', + listener: TNamedNodeMapListener + ): void { + this.#eventListeners[type].push(new WeakRef(listener)); + } + + /** + * Removes event listener. + * + * @param type Type. + * @param listener Listener. + */ + public [PropertySymbol.removeEventListener]( + type: 'set' | 'remove', + listener: TNamedNodeMapListener + ): void { + const listeners = this.#eventListeners[type]; + for (let i = 0, max = listeners.length; i < max; i++) { + if (listeners[i].deref() === listener) { + listeners.splice(i, 1); + return; + } + } + } + + /** + * Dispatches event. + * + * @param type Type. + * @param attribute Attribute. + * @param replacedAttribute Replaced attribute. + */ + public [PropertySymbol.dispatchEvent]( + type: 'set' | 'remove', + attribute: Attr, + replacedAttribute?: Attr | null + ): void { + const listeners = this.#eventListeners[type]; + for (let i = 0, max = listeners.length; i < max; i++) { + const listener = listeners[i].deref(); + if (listener) { + listener(attribute, replacedAttribute); + } else { + listeners.splice(i, 1); + i--; + max--; + } + } + } + + /** + * Sets named item. + * + * @param item Item. + * @param [ignoreListeners] Ignores listeners. + * @returns Replaced item. + */ + public [PropertySymbol.setNamedItem](item: Attr, ignoreListeners = false): Attr | null { + if (!item[PropertySymbol.name]) { + return null; + } + + item[PropertySymbol.name] = this.#getAttributeName(item[PropertySymbol.name]); + (item[PropertySymbol.ownerElement]) = this[PropertySymbol.ownerElement]; + + if (this[PropertySymbol.ownerElement][PropertySymbol.isConnected]) { + this[PropertySymbol.ownerElement][PropertySymbol.ownerDocument][PropertySymbol.cacheID]++; + } + + const name = item[PropertySymbol.name]; + const replacedItem = this[PropertySymbol.namedItems].get(name) || null; + + this[PropertySymbol.namedItems].set(name, item); + + if (replacedItem) { + this.#removeNamedItemIndex(replacedItem); + } + + this[this.length] = item; + this.length++; + + if (this.#isValidPropertyName(name)) { + this[name] = item; + } + + if (!ignoreListeners && replacedItem?.value !== item.value) { + this[PropertySymbol.dispatchEvent]('set', item, replacedItem); + } + + return replacedItem; + } + + /** + * Removes an item without throwing if it doesn't exist. + * + * @param name Name of item. + * @param [ignoreListeners] Ignores listeners. + * @returns Removed item, or null if it didn't exist. + */ + public [PropertySymbol.removeNamedItem](name: string, ignoreListeners = false): Attr | null { + const removedItem = this[PropertySymbol.namedItems].get(this.#getAttributeName(name)); + + if (!removedItem) { + return null; + } + + this.#removeNamedItemIndex(removedItem); + + if (this[name] === removedItem) { + delete this[name]; + } + + this[PropertySymbol.namedItems].delete(name); + + if (!ignoreListeners) { + this[PropertySymbol.dispatchEvent]('remove', removedItem); + } + + return removedItem; + } + + /** + * Removes an item from index. + * + * @param item Item. + */ + #removeNamedItemIndex(item: Attr): void { + for (let i = 0; i < this.length; i++) { + if (this[i] === item) { + for (let b = i; b < this.length; b++) { + if (b < this.length - 1) { + this[b] = this[b + 1]; + } else { + delete this[b]; + } + } + this.length--; + break; + } + } + } + + /** + * Returns "true" if the property name is valid. + * + * @param name Name. + * @returns True if the property name is valid. + */ + #isValidPropertyName(name: string): boolean { + return ( + !!name && + !this.constructor.prototype.hasOwnProperty(name) && + (isNaN(Number(name)) || name.includes('.')) + ); + } + + /** + * Returns attribute name. + * + * @param name Name. + * @returns Attribute name based on namespace. + */ + #getAttributeName(name): string { + if (this[PropertySymbol.ownerElement][PropertySymbol.namespaceURI] === NamespaceURI.svg) { + return name; + } + return name.toLowerCase(); + } +} diff --git a/packages/happy-dom/src/nodes/element/THTMLCollectionListener.ts b/packages/happy-dom/src/nodes/element/THTMLCollectionListener.ts new file mode 100644 index 00000000..9c4f6736 --- /dev/null +++ b/packages/happy-dom/src/nodes/element/THTMLCollectionListener.ts @@ -0,0 +1,7 @@ +type THTMLCollectionListener = (details: { + index?: number; + item?: T; + propertyName?: string; + propertyValue?: any; +}) => void; +export default THTMLCollectionListener; diff --git a/packages/happy-dom/src/nodes/element/TNamedNodeMapListener.ts b/packages/happy-dom/src/nodes/element/TNamedNodeMapListener.ts new file mode 100644 index 00000000..d3238a87 --- /dev/null +++ b/packages/happy-dom/src/nodes/element/TNamedNodeMapListener.ts @@ -0,0 +1,4 @@ +import Attr from '../attr/Attr.js'; + +type TNamedNodeMapListener = (attribute: Attr, replacedAttribute?: Attr | null) => void; +export default TNamedNodeMapListener; diff --git a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts index 5c581388..d1b0c34e 100644 --- a/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts +++ b/packages/happy-dom/src/nodes/html-anchor-element/HTMLAnchorElement.ts @@ -1,13 +1,12 @@ import HTMLElement from '../html-element/HTMLElement.js'; import * as PropertySymbol from '../../PropertySymbol.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLHyperlinkElementNamedNodeMap from '../html-hyperlink-element/HTMLHyperlinkElementNamedNodeMap.js'; import Event from '../../event/Event.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; import PointerEvent from '../../event/events/PointerEvent.js'; import HTMLHyperlinkElementUtility from '../html-hyperlink-element/HTMLHyperlinkElementUtility.js'; import IHTMLHyperlinkElement from '../html-hyperlink-element/IHTMLHyperlinkElement.js'; +import Attr from '../attr/Attr.js'; /** * HTML Anchor Element. @@ -16,12 +15,24 @@ import IHTMLHyperlinkElement from '../html-hyperlink-element/IHTMLHyperlinkEleme * https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement. */ export default class HTMLAnchorElement extends HTMLElement implements IHTMLHyperlinkElement { - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLHyperlinkElementNamedNodeMap( - this - ); public [PropertySymbol.relList]: DOMTokenList = null; #htmlHyperlinkElementUtility = new HTMLHyperlinkElementUtility(this); + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + /** * Returns download. * @@ -401,4 +412,25 @@ export default class HTMLAnchorElement extends HTMLElement implements IHTMLHyper return returnValue; } + + /** + * Triggered when an attribute is set. + * @param item + */ + #onSetAttribute(item: Attr): void { + if (item[PropertySymbol.name] === 'rel' && this[PropertySymbol.relList]) { + this[PropertySymbol.relList][PropertySymbol.updateIndices](); + } + } + + /** + * Triggered when an attribute is removed. + * @param name + * @param removedItem + */ + #onRemoveAttribute(removedItem: Attr): void { + if (removedItem[PropertySymbol.name] === 'rel' && this[PropertySymbol.relList]) { + this[PropertySymbol.relList][PropertySymbol.updateIndices](); + } + } } diff --git a/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts b/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts index 2a307180..d5b01bc0 100644 --- a/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts +++ b/packages/happy-dom/src/nodes/html-area-element/HTMLAreaElement.ts @@ -1,13 +1,12 @@ import HTMLElement from '../html-element/HTMLElement.js'; import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLHyperlinkElementNamedNodeMap from '../html-hyperlink-element/HTMLHyperlinkElementNamedNodeMap.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; import HTMLHyperlinkElementUtility from '../html-hyperlink-element/HTMLHyperlinkElementUtility.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; import IHTMLHyperlinkElement from '../html-hyperlink-element/IHTMLHyperlinkElement.js'; import PointerEvent from '../../event/events/PointerEvent.js'; import Event from '../../event/Event.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; +import Attr from '../attr/Attr.js'; /** * HTMLAreaElement @@ -15,12 +14,24 @@ import EventPhaseEnum from '../../event/EventPhaseEnum.js'; * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLAreaElement */ export default class HTMLAreaElement extends HTMLElement implements IHTMLHyperlinkElement { - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLHyperlinkElementNamedNodeMap( - this - ); public [PropertySymbol.relList]: DOMTokenList = null; #htmlHyperlinkElementUtility = new HTMLHyperlinkElementUtility(this); + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + /** * Returns alt. * @@ -400,4 +411,25 @@ export default class HTMLAreaElement extends HTMLElement implements IHTMLHyperli return returnValue; } + + /** + * Triggered when an attribute is set. + * @param item + */ + #onSetAttribute(item: Attr): void { + if (item[PropertySymbol.name] === 'rel' && this[PropertySymbol.relList]) { + this[PropertySymbol.relList][PropertySymbol.updateIndices](); + } + } + + /** + * Triggered when an attribute is removed. + * @param name + * @param removedItem + */ + #onRemoveAttribute(removedItem: Attr): void { + if (removedItem[PropertySymbol.name] === 'rel' && this[PropertySymbol.relList]) { + this[PropertySymbol.relList][PropertySymbol.updateIndices](); + } + } } diff --git a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts index 216daffd..10b256ff 100644 --- a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts +++ b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElement.ts @@ -1,7 +1,6 @@ import Event from '../../event/Event.js'; import * as PropertySymbol from '../../PropertySymbol.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; import ValidityState from '../../validity-state/ValidityState.js'; import HTMLElement from '../html-element/HTMLElement.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; @@ -9,9 +8,11 @@ import HTMLLabelElementUtility from '../html-label-element/HTMLLabelElementUtili import HTMLLabelElement from '../html-label-element/HTMLLabelElement.js'; import Node from '../node/Node.js'; import NodeList from '../node/NodeList.js'; -import HTMLButtonElementNamedNodeMap from './HTMLButtonElementNamedNodeMap.js'; import PointerEvent from '../../event/events/PointerEvent.js'; import { URL } from 'url'; +import HTMLFieldSetElement from '../html-field-set-element/HTMLFieldSetElement.js'; +import Document from '../document/Document.js'; +import Attr from '../attr/Attr.js'; const BUTTON_TYPES = ['submit', 'reset', 'button', 'menu']; @@ -22,11 +23,24 @@ const BUTTON_TYPES = ['submit', 'reset', 'button', 'menu']; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement. */ export default class HTMLButtonElement extends HTMLElement { - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLButtonElementNamedNodeMap( - this - ); public [PropertySymbol.validationMessage] = ''; public [PropertySymbol.validity] = new ValidityState(this); + public [PropertySymbol.formNode]: HTMLFormElement | null = null; + + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } /** * Returns validation message. @@ -232,17 +246,19 @@ export default class HTMLButtonElement extends HTMLElement { * * @returns Form. */ - public get form(): HTMLFormElement | null { - if (this[PropertySymbol.formNode]) { - return this[PropertySymbol.formNode]; - } - if (!this.isConnected) { - return null; - } + public get form(): HTMLFormElement { const formID = this.getAttribute('form'); - return formID - ? this[PropertySymbol.ownerDocument].getElementById(formID) - : null; + + if (formID !== null) { + if (!this[PropertySymbol.isConnected]) { + return null; + } + return formID + ? (this[PropertySymbol.rootNode]).getElementById(formID) + : null; + } + + return this[PropertySymbol.formNode]; } /** @@ -328,32 +344,6 @@ export default class HTMLButtonElement extends HTMLElement { return returnValue; } - /** - * @override - */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const oldFormNode = this[PropertySymbol.formNode]; - - super[PropertySymbol.connectToNode](parentNode); - - if (oldFormNode !== this[PropertySymbol.formNode]) { - if (oldFormNode) { - oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); - oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); - } - if (this[PropertySymbol.formNode]) { - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.name - ); - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.id - ); - } - } - } - /** * Sanitizes type. * diff --git a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts deleted file mode 100644 index acc458ca..00000000 --- a/packages/happy-dom/src/nodes/html-button-element/HTMLButtonElementNamedNodeMap.ts +++ /dev/null @@ -1,58 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; -import HTMLButtonElement from './HTMLButtonElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLButtonElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLButtonElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - if (replacedItem?.[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); - } - if (item[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.appendFormControlItem - ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-data-list-element/HTMLDataListElement.ts b/packages/happy-dom/src/nodes/html-data-list-element/HTMLDataListElement.ts index 73647251..d66aaa24 100644 --- a/packages/happy-dom/src/nodes/html-data-list-element/HTMLDataListElement.ts +++ b/packages/happy-dom/src/nodes/html-data-list-element/HTMLDataListElement.ts @@ -1,6 +1,6 @@ import HTMLElement from '../html-element/HTMLElement.js'; import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLCollection from '../element/HTMLCollection.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; import HTMLOptionElement from '../html-option-element/HTMLOptionElement.js'; import Node from '../node/Node.js'; diff --git a/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElement.ts b/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElement.ts index 9e2cc05a..df9dd6ad 100644 --- a/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElement.ts +++ b/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElement.ts @@ -1,8 +1,7 @@ import Event from '../../event/Event.js'; import HTMLElement from '../html-element/HTMLElement.js'; import * as PropertySymbol from '../../PropertySymbol.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLDetailsElementNamedNodeMap from './HTMLDetailsElementNamedNodeMap.js'; +import Attr from '../attr/Attr.js'; /** * HTMLDetailsElement @@ -10,13 +9,24 @@ import HTMLDetailsElementNamedNodeMap from './HTMLDetailsElementNamedNodeMap.js' * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement */ export default class HTMLDetailsElement extends HTMLElement { - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLDetailsElementNamedNodeMap( - this - ); - // Events public ontoggle: (event: Event) => void | null = null; + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + /** * Returns the open attribute. */ @@ -36,4 +46,29 @@ export default class HTMLDetailsElement extends HTMLElement { this.removeAttribute('open'); } } + + /** + * Triggered when an attribute is set. + * + * @param attribute Attribute + * @param replacedAttribute Replaced item + */ + #onSetAttribute(attribute: Attr, replacedAttribute: Attr | null): void { + if (attribute[PropertySymbol.name] === 'open') { + if (attribute[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value]) { + this.dispatchEvent(new Event('toggle')); + } + } + } + + /** + * Triggered when an attribute is removed. + * + * @param removedAttribute Removed attribute. + */ + #onRemoveAttribute(removedAttribute: Attr): void { + if (removedAttribute && removedAttribute[PropertySymbol.name] === 'open') { + this.dispatchEvent(new Event('toggle')); + } + } } diff --git a/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElementNamedNodeMap.ts deleted file mode 100644 index 00e6f956..00000000 --- a/packages/happy-dom/src/nodes/html-details-element/HTMLDetailsElementNamedNodeMap.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import Event from '../../event/Event.js'; -import HTMLDetailsElement from './HTMLDetailsElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLDetailsElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLDetailsElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if (item[PropertySymbol.name] === 'open') { - if (item[PropertySymbol.value] !== replacedItem?.[PropertySymbol.value]) { - this[PropertySymbol.ownerElement].dispatchEvent(new Event('toggle')); - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if (removedItem && removedItem[PropertySymbol.name] === 'open') { - this[PropertySymbol.ownerElement].dispatchEvent(new Event('toggle')); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index 6022e22a..91a11914 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -6,13 +6,13 @@ import NodeTypeEnum from '../node/NodeTypeEnum.js'; import DOMException from '../../exception/DOMException.js'; import Event from '../../event/Event.js'; import HTMLElementUtility from './HTMLElementUtility.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLElementNamedNodeMap from './HTMLElementNamedNodeMap.js'; import NodeList from '../node/NodeList.js'; import Node from '../node/Node.js'; -import HTMLCollection from '../element/HTMLCollection.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; import DatasetFactory from '../element/DatasetFactory.js'; import IDataset from '../element/IDataset.js'; +import Attr from '../attr/Attr.js'; +import NamedNodeMap from '../element/NamedNodeMap.js'; /** * HTML Element. @@ -52,7 +52,6 @@ export default class HTMLElement extends Element { public ontransitionstart: (event: Event) => void | null = null; // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLElementNamedNodeMap(this); public [PropertySymbol.accessKey] = ''; public [PropertySymbol.contentEditable] = 'inherit'; public [PropertySymbol.isContentEditable] = false; @@ -70,6 +69,21 @@ export default class HTMLElement extends Element { #dataset: IDataset = null; #customElementDefineCallback: () => void = null; + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + /** * Returns access key. * @@ -275,17 +289,20 @@ export default class HTMLElement extends Element { * @param innerText Inner text. */ public set innerText(text: string) { - for (const child of this[PropertySymbol.childNodes].slice()) { - this.removeChild(child); + const childNodes = this[PropertySymbol.childNodes][PropertySymbol.items]; + + while (childNodes.length) { + this.removeChild(childNodes[0]); } const texts = text.split(/[\n\r]/); + const ownerDocument = this[PropertySymbol.ownerDocument]; for (let i = 0, max = texts.length; i < max; i++) { if (i !== 0) { - this.appendChild(this[PropertySymbol.ownerDocument].createElement('br')); + this.appendChild(ownerDocument.createElement('br')); } - this.appendChild(this[PropertySymbol.ownerDocument].createTextNode(texts[i])); + this.appendChild(ownerDocument.createTextNode(texts[i])); } } @@ -506,10 +523,10 @@ export default class HTMLElement extends Element { /** * Connects this element to another element. * + * @override * @see https://html.spec.whatwg.org/multipage/dom.html#htmlelement - * @param parentNode Parent node. */ - public [PropertySymbol.connectToNode](parentNode: Node = null): void { + public [PropertySymbol.connectedToDocument](): void { const localName = this[PropertySymbol.localName]; // This element can potentially be a custom element that has not been defined yet @@ -526,7 +543,7 @@ export default class HTMLElement extends Element { PropertySymbol.callbacks ]; - if (parentNode && !this.#customElementDefineCallback) { + if (!this.#customElementDefineCallback) { const callback = (): void => { if (this[PropertySymbol.parentNode]) { const newElement = ( @@ -553,46 +570,38 @@ export default class HTMLElement extends Element { (>this[PropertySymbol.childNodes]) = new NodeList(); (>this[PropertySymbol.children]) = new HTMLCollection(); + this[PropertySymbol.childNodes][PropertySymbol.attachHTMLCollection]( + this[PropertySymbol.children] + ); this[PropertySymbol.rootNode] = null; this[PropertySymbol.formNode] = null; this[PropertySymbol.selectNode] = null; this[PropertySymbol.textAreaNode] = null; this[PropertySymbol.observers] = []; this[PropertySymbol.isValue] = null; - (this[PropertySymbol.attributes]) = - new HTMLElementNamedNodeMap(this); - - for ( - let i = 0, - max = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes] - .length; - i < max; - i++ - ) { - if ( - (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] === - this - ) { - (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] = - newElement; + (this[PropertySymbol.attributes]) = new NamedNodeMap(this); + + const childNodes = (this[PropertySymbol.parentNode])[ + PropertySymbol.childNodes + ]?.[PropertySymbol.items]; + const childNodesItems = childNodes[PropertySymbol.items]; + for (let i = 0, max = childNodesItems.length; i < max; i++) { + if (childNodesItems[i] === this) { + (childNodes[i]) = newElement; + (childNodesItems[i]) = newElement; break; } } - if ((this[PropertySymbol.parentNode])[PropertySymbol.children]) { - for ( - let i = 0, - max = (this[PropertySymbol.parentNode])[PropertySymbol.children] - .length; - i < max; - i++ - ) { - if ( - (this[PropertySymbol.parentNode])[PropertySymbol.children][i] === - this - ) { - (this[PropertySymbol.parentNode])[PropertySymbol.children][i] = - newElement; + const children = (this[PropertySymbol.parentNode])[ + PropertySymbol.children + ]; + if (children) { + const childrenItems = children[PropertySymbol.items]; + for (let i = 0, max = childrenItems.length; i < max; i++) { + if (childrenItems[i] === this) { + children[i] = newElement; + childrenItems[i] = newElement; break; } } @@ -602,13 +611,40 @@ export default class HTMLElement extends Element { newElement.connectedCallback(); } - this[PropertySymbol.connectToNode](null); + this[PropertySymbol.connectedToDocument](null); } }; callbacks[localName] = callbacks[localName] || []; callbacks[localName].push(callback); this.#customElementDefineCallback = callback; - } else if (!parentNode && callbacks[localName] && this.#customElementDefineCallback) { + } + } + + super[PropertySymbol.connectedToDocument](); + } + + /** + * Called when disconnected from document. + * @param e + */ + public [PropertySymbol.disconnectedFromDocument](): void { + const localName = this[PropertySymbol.localName]; + + // This element can potentially be a custom element that has not been defined yet + // Therefore we need to register a callback for when it is defined in CustomElementRegistry and replace it with the registered element (see #404) + if ( + this.constructor === HTMLElement && + localName.includes('-') && + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ + PropertySymbol.callbacks + ] + ) { + const callbacks = + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ + PropertySymbol.callbacks + ]; + + if (callbacks[localName] && this.#customElementDefineCallback) { const index = callbacks[localName].indexOf(this.#customElementDefineCallback); if (index !== -1) { callbacks[localName].splice(index, 1); @@ -620,6 +656,28 @@ export default class HTMLElement extends Element { } } - super[PropertySymbol.connectToNode](parentNode); + super[PropertySymbol.disconnectedFromDocument](); + } + + /** + * Triggered when an attribute is set. + * + * @param item Item. + */ + #onSetAttribute(item: Attr): void { + if (item[PropertySymbol.name] === 'style' && this[PropertySymbol.style]) { + this[PropertySymbol.style].cssText = item[PropertySymbol.value]; + } + } + + /** + * Triggered when an attribute is removed. + * + * @param removedItem Removed item. + */ + #onRemoveAttribute(removedItem: Attr): void { + if (removedItem && removedItem[PropertySymbol.name] === 'style' && this[PropertySymbol.style]) { + this[PropertySymbol.style].cssText = ''; + } } } diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts deleted file mode 100644 index f915e7cd..00000000 --- a/packages/happy-dom/src/nodes/html-element/HTMLElementNamedNodeMap.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import ElementNamedNodeMap from '../element/ElementNamedNodeMap.js'; -import HTMLElement from './HTMLElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLElementNamedNodeMap extends ElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - item[PropertySymbol.name] === 'style' && - this[PropertySymbol.ownerElement][PropertySymbol.style] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = item[PropertySymbol.value]; - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - removedItem[PropertySymbol.name] === 'style' && - this[PropertySymbol.ownerElement][PropertySymbol.style] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = ''; - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-field-set-element/HTMLFieldSetElement.ts b/packages/happy-dom/src/nodes/html-field-set-element/HTMLFieldSetElement.ts index 00785085..f8a68838 100644 --- a/packages/happy-dom/src/nodes/html-field-set-element/HTMLFieldSetElement.ts +++ b/packages/happy-dom/src/nodes/html-field-set-element/HTMLFieldSetElement.ts @@ -1,7 +1,188 @@ import HTMLElement from '../html-element/HTMLElement.js'; +import * as PropertySymbol from '../../PropertySymbol.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; +import HTMLInputElement from '../html-input-element/HTMLInputElement.js'; +import HTMLTextAreaElement from '../html-text-area-element/HTMLTextAreaElement.js'; +import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; +import HTMLButtonElement from '../html-button-element/HTMLButtonElement.js'; +import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; +import Document from '../document/Document.js'; +import Attr from '../attr/Attr.js'; + /** * HTMLFieldSetElement * * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLFieldSetElement */ -export default class HTMLFieldSetElement extends HTMLElement {} +export default class HTMLFieldSetElement extends HTMLElement { + // Internal properties + public [PropertySymbol.elements] = new HTMLCollection< + HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement | HTMLSelectElement + >(); + public [PropertySymbol.formNode]: HTMLFormElement | null = null; + + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + + /** + * Returns elements. + * + * @returns Elements. + */ + public get elements(): HTMLCollection< + HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement | HTMLSelectElement + > { + return this[PropertySymbol.elements]; + } + + /** + * Returns the parent form element. + * + * @returns Form. + */ + public get form(): HTMLFormElement { + const formID = this.getAttribute('form'); + + if (formID !== null) { + if (!this[PropertySymbol.isConnected]) { + return null; + } + return formID + ? (this[PropertySymbol.rootNode]).getElementById(formID) + : null; + } + + return this[PropertySymbol.formNode]; + } + + /** + * Returns name. + * + * @returns Name. + */ + public get name(): string { + return this.getAttribute('name') || ''; + } + + /** + * Sets name. + * + * @param name Name. + */ + public set name(name: string) { + this.setAttribute('name', name); + } + + /** + * Returns type "fieldset". + * + * @returns Type. + */ + public get type(): string { + return 'fieldset'; + } + + /** + * Returns empty string as fieldset never candidates for constraint validation. + */ + public get validationMessage(): string { + return ''; + } + + /** + * Returns will validate state. + * + * Always returns false as fieldset never candidates for constraint validation. + * + * @returns Will validate state. + */ + public get willValidate(): boolean { + return false; + } + + /** + * Returns disabled. + * + * @returns Disabled. + */ + public get disabled(): boolean { + return this.getAttribute('disabled') !== null; + } + + /** + * Sets disabled. + * + * @param disabled Disabled. + */ + public set disabled(disabled: boolean) { + if (!disabled) { + this.removeAttribute('disabled'); + } else { + this.setAttribute('disabled', ''); + } + } + + /** + * Checks validity. + * + * Always returns true as fieldset never candidates for constraint validation. + * + * @returns "true" if the field is valid. + */ + public checkValidity(): boolean { + return true; + } + + /** + * Reports validity. + * + * Always returns true as fieldset never candidates for constraint validation. + * + * @returns Validity. + */ + public reportValidity(): boolean { + return true; + } + + /** + * Sets validation message. + * + * Does nothing as fieldset never candidates for constraint validation. + * + * @param _message Message. + */ + public setCustomValidity(_message: string): void { + // Do nothing as fieldset never candidates for constraint validation. + } + + /** + * Triggered when an attribute is set. + * + * @param attribute Attribute. + * @param replacedAttribute Replaced attribute. + */ + #onSetAttribute(attribute: Attr, replacedAttribute: Attr | null): void { + this.form?.[PropertySymbol.appendFormControlItem](this, attribute, replacedAttribute); + } + + /** + * Triggered when an attribute is removed. + * + * @param removedAttribute Removed attribute. + */ + #onRemoveAttribute(removedAttribute: Attr): void { + this.form?.[PropertySymbol.removeFormControlItem](this, removedAttribute); + } +} diff --git a/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts b/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts index d35da7bb..a0cb921d 100644 --- a/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts +++ b/packages/happy-dom/src/nodes/html-form-element/HTMLFormControlsCollection.ts @@ -1,126 +1,189 @@ import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLInputElement from '../html-input-element/HTMLInputElement.js'; -import HTMLTextAreaElement from '../html-text-area-element/HTMLTextAreaElement.js'; -import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; +import Attr from '../attr/Attr.js'; +import Element from '../element/Element.js'; +import HTMLCollection from '../element/HTMLCollection.js'; +import TNamedNodeMapListener from '../element/TNamedNodeMapListener.js'; +import HTMLFormElement from './HTMLFormElement.js'; import RadioNodeList from './RadioNodeList.js'; -import HTMLButtonElement from '../html-button-element/HTMLButtonElement.js'; +import THTMLFormControlElement from './THTMLFormControlElement.js'; /** * HTMLFormControlsCollection. * * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection */ -export default class HTMLFormControlsCollection - extends Array - implements HTMLFormControlsCollection -{ - public [PropertySymbol.namedItems]: { [k: string]: RadioNodeList } = {}; +export default class HTMLFormControlsCollection extends HTMLCollection< + THTMLFormControlElement, + THTMLFormControlElement | RadioNodeList +> { + #namedNodeMapListeners = new Map(); /** - * Returns item by index. - * - * @param index Index. + * Constructor. + * @param formElement */ - public item( - index: number - ): HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement | null { - return index >= 0 && this[index] ? this[index] : null; + constructor(formElement: HTMLFormElement) { + super((item: Element) => { + if ( + item[PropertySymbol.tagName] !== 'INPUT' && + item[PropertySymbol.tagName] !== 'SELECT' && + item[PropertySymbol.tagName] !== 'TEXTAREA' && + item[PropertySymbol.tagName] !== 'BUTTON' && + item[PropertySymbol.tagName] !== 'FIELDSET' + ) { + return false; + } + if (formElement[PropertySymbol.childNodesFlatten][PropertySymbol.includes](item)) { + return true; + } + if ( + !item[PropertySymbol.attributes]['form'] || + !formElement[PropertySymbol.attributes]['id'] + ) { + return false; + } + return ( + item[PropertySymbol.attributes]['form'].value === + formElement[PropertySymbol.attributes]['id'].value + ); + }); } /** - * Returns named item. + * Appends item. * - * @param name Name. - * @returns Node. + * @param item Item. + * @returns True if added. */ - public namedItem( - name: string - ): - | HTMLInputElement - | HTMLTextAreaElement - | HTMLSelectElement - | HTMLButtonElement - | RadioNodeList - | null { - if (this[PropertySymbol.namedItems][name] && this[PropertySymbol.namedItems][name].length) { - if (this[PropertySymbol.namedItems][name].length === 1) { - return this[PropertySymbol.namedItems][name][0]; - } - return this[PropertySymbol.namedItems][name]; + public [PropertySymbol.addItem](item: THTMLFormControlElement): boolean { + const returnValue = super[PropertySymbol.addItem](item); + + if (!returnValue) { + return false; } - return null; + + const listener = (attribute: Attr): void => { + if (attribute.name === 'form') { + this[PropertySymbol.removeItem](item); + this[PropertySymbol.addItem](item); + } + }; + + this.#namedNodeMapListeners.set(item, listener); + item[PropertySymbol.attributes][PropertySymbol.addEventListener]('set', listener); + item[PropertySymbol.attributes][PropertySymbol.addEventListener]('remove', listener); + + return true; } /** - * Appends named item. + * Inserts item before another item. * - * @param node Node. - * @param name Name. + * @param newItem New item. + * @param [referenceItem] Reference item. + * @returns True if inserted. */ - public [PropertySymbol.appendNamedItem]( - node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement, - name: string - ): void { - if (name) { - this[PropertySymbol.namedItems][name] = - this[PropertySymbol.namedItems][name] || new RadioNodeList(); - - if (!this[PropertySymbol.namedItems][name].includes(node)) { - this[PropertySymbol.namedItems][name].push(node); - } + public [PropertySymbol.insertItem]( + newItem: THTMLFormControlElement, + referenceItem: THTMLFormControlElement | null + ): boolean { + const returnValue = super[PropertySymbol.insertItem](newItem, referenceItem); + + if (!returnValue) { + return false; + } - if (this[PropertySymbol.isValidPropertyName](name)) { - this[name] = - this[PropertySymbol.namedItems][name].length > 1 - ? this[PropertySymbol.namedItems][name] - : this[PropertySymbol.namedItems][name][0]; + const listener = (attribute: Attr): void => { + if (attribute.name === 'form') { + this[PropertySymbol.removeItem](newItem); + this[PropertySymbol.insertItem](newItem, referenceItem); } + }; + + this.#namedNodeMapListeners.set(newItem, listener); + newItem[PropertySymbol.attributes][PropertySymbol.addEventListener]('set', listener); + newItem[PropertySymbol.attributes][PropertySymbol.addEventListener]('remove', listener); + + return true; + } + + /** + * Removes item. + * + * @param item Item. + * @returns True if removed. + */ + public [PropertySymbol.removeItem](item: THTMLFormControlElement): boolean { + const returnValue = super[PropertySymbol.removeItem](item); + + if (!returnValue) { + return false; } + + const listener = this.#namedNodeMapListeners.get(item); + + item[PropertySymbol.attributes][PropertySymbol.removeEventListener]('set', listener); + item[PropertySymbol.attributes][PropertySymbol.removeEventListener]('remove', listener); + + return true; } /** - * Appends named item. + * @override + */ + public namedItem(name: string): THTMLFormControlElement | RadioNodeList | null { + const namedItems = this[PropertySymbol.namedItems].get(name); + + if (!namedItems?.length) { + return null; + } + + if (namedItems.length === 1) { + return namedItems[0]; + } + + return namedItems; + } + + /** + * Returns named items. * - * @param node Node. * @param name Name. + * @returns Named items. */ - public [PropertySymbol.removeNamedItem]( - node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement, - name: string - ): void { - if (name && this[PropertySymbol.namedItems][name]) { - const index = this[PropertySymbol.namedItems][name].indexOf(node); - - if (index > -1) { - this[PropertySymbol.namedItems][name].splice(index, 1); - - if (this[PropertySymbol.namedItems][name].length === 0) { - delete this[PropertySymbol.namedItems][name]; - if (this.hasOwnProperty(name) && this[PropertySymbol.isValidPropertyName](name)) { - delete this[name]; - } - } else if (this[PropertySymbol.isValidPropertyName](name)) { - this[name] = - this[PropertySymbol.namedItems][name].length > 1 - ? this[PropertySymbol.namedItems][name] - : this[PropertySymbol.namedItems][name][0]; - } - } - } + protected [PropertySymbol.getNamedItems](name: string): RadioNodeList { + return this[PropertySymbol.namedItems].get(name) || new RadioNodeList(); } /** - * Returns "true" if the property name is valid. + * Sets named item property. * * @param name Name. - * @returns True if the property name is valid. */ - protected [PropertySymbol.isValidPropertyName](name: string): boolean { - return ( - !!name && - !this.constructor.prototype.hasOwnProperty(name) && - !Array.prototype.hasOwnProperty(name) && - (isNaN(Number(name)) || name.includes('.')) - ); + protected [PropertySymbol.setNamedItemProperty](name: string): void { + if (!this[PropertySymbol.isValidPropertyName](name)) { + return; + } + + const namedItems = this[PropertySymbol.namedItems].get(name); + + if (namedItems?.length) { + const newValue = namedItems.length === 1 ? namedItems[0] : namedItems; + if (Object.getOwnPropertyDescriptor(this, name)?.value !== newValue) { + Object.defineProperty(this, name, { + value: newValue, + writable: false, + enumerable: true, + configurable: true + }); + } + } else { + delete this[name]; + } + + this[PropertySymbol.dispatchEvent]('propertyChange', { + propertyName: name, + propertyValue: this[name] ?? null + }); } } diff --git a/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts b/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts index 553de7bf..c5c1a3c4 100644 --- a/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts +++ b/packages/happy-dom/src/nodes/html-form-element/HTMLFormElement.ts @@ -5,7 +5,6 @@ import SubmitEvent from '../../event/events/SubmitEvent.js'; import HTMLFormControlsCollection from './HTMLFormControlsCollection.js'; import Node from '../node/Node.js'; import HTMLInputElement from '../html-input-element/HTMLInputElement.js'; -import HTMLTextAreaElement from '../html-text-area-element/HTMLTextAreaElement.js'; import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; import HTMLButtonElement from '../html-button-element/HTMLButtonElement.js'; import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; @@ -13,6 +12,8 @@ import BrowserFrameNavigator from '../../browser/utilities/BrowserFrameNavigator import FormData from '../../form-data/FormData.js'; import Element from '../element/Element.js'; import BrowserWindow from '../../window/BrowserWindow.js'; +import Attr from '../attr/Attr.js'; +import THTMLFormControlElement from './THTMLFormControlElement.js'; /** * HTML Form Element. @@ -25,7 +26,9 @@ export default class HTMLFormElement extends HTMLElement { public cloneNode: (deep?: boolean) => HTMLFormElement; // Internal properties. - public [PropertySymbol.elements]: HTMLFormControlsCollection = new HTMLFormControlsCollection(); + public [PropertySymbol.elements]: HTMLFormControlsCollection = new HTMLFormControlsCollection( + this + ); public [PropertySymbol.length] = 0; public [PropertySymbol.formNode]: Node = this; @@ -36,6 +39,11 @@ export default class HTMLFormElement extends HTMLElement { // Private properties #browserFrame: IBrowserFrame; + #documentChildNodeListeners: { + add: (item: Node) => void; + insert: (newItem: Node, referenceItem: Node | null) => void; + remove: (item: Node) => void; + } | null = null; /** * Constructor. @@ -45,6 +53,61 @@ export default class HTMLFormElement extends HTMLElement { constructor(browserFrame: IBrowserFrame) { super(); this.#browserFrame = browserFrame; + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + + // Child nodes listeners + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]('add', (item: Node) => { + (item)[PropertySymbol.formNode] = this; + this[PropertySymbol.elements][PropertySymbol.addItem](item); + }); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]( + 'insert', + (newItem: Node, referenceItem: Node | null) => { + (newItem)[PropertySymbol.formNode] = this; + this[PropertySymbol.elements][PropertySymbol.insertItem]( + newItem, + referenceItem + ); + } + ); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]( + 'remove', + (item: Node) => { + (item)[PropertySymbol.formNode] = null; + this[PropertySymbol.elements][PropertySymbol.removeItem](item); + } + ); + + // Form controls listeners + this[PropertySymbol.elements][PropertySymbol.addEventListener]('indexChange', (details) => { + const length = this[PropertySymbol.elements].length; + this[PropertySymbol.length] = length; + for (let i = details.index; i < length; i++) { + this[i] = this[PropertySymbol.elements][i]; + } + }); + this[PropertySymbol.elements][PropertySymbol.addEventListener]('propertyChange', (details) => { + if (!this[PropertySymbol.isValidPropertyName](details.propertyName)) { + return; + } + if (details.propertyValue) { + Object.defineProperty(this, details.propertyName, { + value: details.propertyValue, + writable: false, + enumerable: true, + configurable: true + }); + } else { + delete this[details.propertyName]; + } + }); } /** @@ -335,59 +398,100 @@ export default class HTMLFormElement extends HTMLElement { } /** - * Appends a form control item. - * - * @param node Node. - * @param name Name - */ - public [PropertySymbol.appendFormControlItem]( - node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement, - name: string - ): void { - const elements = this[PropertySymbol.elements]; - - if (!elements.includes(node)) { - this[elements.length] = node; - elements.push(node); - this[PropertySymbol.length] = elements.length; - } + * @override + */ + public override [PropertySymbol.connectedToDocument](): void { + super[PropertySymbol.connectedToDocument](); + + // Document child nodes listeners + this.#documentChildNodeListeners = { + add: (item: Node) => { + if (!this[PropertySymbol.isConnected]) { + return; + } + (item)[PropertySymbol.formNode] = this; + this[PropertySymbol.elements][PropertySymbol.addItem](item); + }, + insert: (newItem: Node, referenceItem: Node | null) => { + if (!this[PropertySymbol.isConnected]) { + return; + } + (newItem)[PropertySymbol.formNode] = this; + this[PropertySymbol.elements][PropertySymbol.insertItem]( + newItem, + referenceItem + ); + }, + remove: (item: Node) => { + if (!this[PropertySymbol.isConnected]) { + return; + } + (item)[PropertySymbol.formNode] = null; + this[PropertySymbol.elements][PropertySymbol.removeItem](item); + } + }; + + this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten][ + PropertySymbol.addEventListener + ]('add', this.#documentChildNodeListeners.add); + this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten][ + PropertySymbol.addEventListener + ]('insert', this.#documentChildNodeListeners.insert); + this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten][ + PropertySymbol.addEventListener + ]('remove', this.#documentChildNodeListeners.remove); + + const id = this.id; - (elements)[PropertySymbol.appendNamedItem](node, name); + if (!id) { + return; + } - if (this[PropertySymbol.isValidPropertyName](name)) { - this[name] = elements[name]; + for (const node of this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten]) { + if ( + node[PropertySymbol.attributes]?.['form']?.value === id && + node[PropertySymbol.formNode] !== this + ) { + node[PropertySymbol.formNode] = this; + this[PropertySymbol.elements][PropertySymbol.addItem](node); + } } } /** - * Remove a form control item. - * - * @param node Node. - * @param name Name. + * @override */ - public [PropertySymbol.removeFormControlItem]( - node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement, - name: string - ): void { - const elements = this[PropertySymbol.elements]; - const index = elements.indexOf(node); - - if (index !== -1) { - elements.splice(index, 1); - for (let i = index; i < this[PropertySymbol.length]; i++) { - this[i] = this[i + 1]; - } - delete this[this[PropertySymbol.length] - 1]; - this[PropertySymbol.length]--; + public override [PropertySymbol.disconnectedFromDocument](): void { + super[PropertySymbol.disconnectedFromDocument](); + + if (!this.#documentChildNodeListeners) { + return; } - (elements)[PropertySymbol.removeNamedItem](node, name); + // Document child nodes listeners + this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten][ + PropertySymbol.removeEventListener + ]('add', this.#documentChildNodeListeners.add); + this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten][ + PropertySymbol.removeEventListener + ]('insert', this.#documentChildNodeListeners.insert); + this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten][ + PropertySymbol.removeEventListener + ]('remove', this.#documentChildNodeListeners.remove); - if (this[PropertySymbol.isValidPropertyName](name)) { - if (elements[name]) { - this[name] = elements[name]; - } else { - delete this[name]; + const id = this.id; + + if (!id) { + return; + } + + for (const node of this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten]) { + if ( + node[PropertySymbol.attributes]?.['form']?.value === id && + !this[PropertySymbol.childNodesFlatten][PropertySymbol.includes](node) + ) { + node[PropertySymbol.formNode] = null; + this[PropertySymbol.elements][PropertySymbol.removeItem](node); } } } @@ -484,4 +588,64 @@ export default class HTMLFormElement extends HTMLElement { } }); } + + /** + * Triggered when an attribute is set. + * + * @param attribute Attribute. + * @param replacedAttribute Replaced attribute. + */ + #onSetAttribute(attribute: Attr, replacedAttribute: Attr | null): void { + if (attribute.name !== 'id' || !this[PropertySymbol.isConnected]) { + return; + } + + if (replacedAttribute[PropertySymbol.value]) { + for (const node of this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten]) { + if ( + node[PropertySymbol.attributes]?.['form']?.value === replacedAttribute.value && + !this[PropertySymbol.childNodesFlatten][PropertySymbol.includes](node) + ) { + node[PropertySymbol.formNode] = null; + this[PropertySymbol.elements][PropertySymbol.removeItem](node); + } + } + } + + if (attribute[PropertySymbol.value]) { + for (const node of this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten]) { + if ( + node[PropertySymbol.attributes]?.['form']?.value === attribute[PropertySymbol.value] && + node[PropertySymbol.formNode] !== this + ) { + node[PropertySymbol.formNode] = this; + this[PropertySymbol.elements][PropertySymbol.addItem](node); + } + } + } + } + + /** + * Triggered when an attribute is removed. + * + * @param removedAttribute Removed attribute. + */ + #onRemoveAttribute(removedAttribute: Attr): void { + if ( + removedAttribute.name === 'id' && + removedAttribute[PropertySymbol.value] && + this[PropertySymbol.isConnected] + ) { + for (const node of this[PropertySymbol.ownerDocument][PropertySymbol.childNodesFlatten]) { + if ( + node[PropertySymbol.attributes]?.['form']?.value === + removedAttribute[PropertySymbol.value] && + !this[PropertySymbol.childNodesFlatten][PropertySymbol.includes](node) + ) { + node[PropertySymbol.formNode] = null; + this[PropertySymbol.elements][PropertySymbol.removeItem](node); + } + } + } + } } diff --git a/packages/happy-dom/src/nodes/html-form-element/RadioNodeList.ts b/packages/happy-dom/src/nodes/html-form-element/RadioNodeList.ts index 6f36a011..5b9d4152 100644 --- a/packages/happy-dom/src/nodes/html-form-element/RadioNodeList.ts +++ b/packages/happy-dom/src/nodes/html-form-element/RadioNodeList.ts @@ -1,17 +1,13 @@ -import HTMLButtonElement from '../html-button-element/HTMLButtonElement.js'; import HTMLInputElement from '../html-input-element/HTMLInputElement.js'; -import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; -import HTMLTextAreaElement from '../html-text-area-element/HTMLTextAreaElement.js'; import NodeList from '../node/NodeList.js'; +import THTMLFormControlElement from './THTMLFormControlElement.js'; /** * RadioNodeList * * @see https://developer.mozilla.org/en-US/docs/Web/API/RadioNodeList */ -export default class RadioNodeList extends NodeList< - HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement -> { +export default class RadioNodeList extends NodeList { /** * Returns value. * diff --git a/packages/happy-dom/src/nodes/html-form-element/THTMLFormControlElement.ts b/packages/happy-dom/src/nodes/html-form-element/THTMLFormControlElement.ts new file mode 100644 index 00000000..129fa78a --- /dev/null +++ b/packages/happy-dom/src/nodes/html-form-element/THTMLFormControlElement.ts @@ -0,0 +1,14 @@ +import HTMLButtonElement from '../html-button-element/HTMLButtonElement.js'; +import HTMLFieldSetElement from '../html-field-set-element/HTMLFieldSetElement.js'; +import HTMLInputElement from '../html-input-element/HTMLInputElement.js'; +import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; +import HTMLTextAreaElement from '../html-text-area-element/HTMLTextAreaElement.js'; + +type THTMLFormControlElement = + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + | HTMLButtonElement + | HTMLFieldSetElement; + +export default THTMLFormControlElement; diff --git a/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementNamedNodeMap.ts deleted file mode 100644 index 771c64f0..00000000 --- a/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementNamedNodeMap.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLAnchorElement from '../html-anchor-element/HTMLAnchorElement.js'; -import HTMLAreaElement from '../html-area-element/HTMLAreaElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLHyperlinkElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLAnchorElement | HTMLAreaElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - item[PropertySymbol.name] === 'rel' && - this[PropertySymbol.ownerElement][PropertySymbol.relList] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem?.[PropertySymbol.name] === 'rel' && - this[PropertySymbol.ownerElement][PropertySymbol.relList] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts index d2568bef..041948e0 100644 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts +++ b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElement.ts @@ -3,13 +3,32 @@ import * as PropertySymbol from '../../PropertySymbol.js'; import BrowserWindow from '../../window/BrowserWindow.js'; import Document from '../document/Document.js'; import HTMLElement from '../html-element/HTMLElement.js'; -import Node from '../node/Node.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLIFrameElementNamedNodeMap from './HTMLIFrameElementNamedNodeMap.js'; import CrossOriginBrowserWindow from '../../window/CrossOriginBrowserWindow.js'; import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; -import HTMLIFrameElementPageLoader from './HTMLIFrameElementPageLoader.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; +import Attr from '../attr/Attr.js'; +import BrowserFrameFactory from '../../browser/utilities/BrowserFrameFactory.js'; +import BrowserFrameURL from '../../browser/utilities/BrowserFrameURL.js'; +import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import IRequestReferrerPolicy from '../../fetch/types/IRequestReferrerPolicy.js'; + +const SANDBOX_FLAGS = [ + 'allow-downloads', + 'allow-forms', + 'allow-modals', + 'allow-orientation-lock', + 'allow-pointer-lock', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-presentation', + 'allow-same-origin', + 'allow-scripts', + 'allow-top-navigation', + 'allow-top-navigation-by-user-activation', + 'allow-top-navigation-to-custom-protocols' +]; /** * HTML Iframe Element. @@ -26,14 +45,14 @@ export default class HTMLIFrameElement extends HTMLElement { public onerror: (event: Event) => void | null = null; // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap; public [PropertySymbol.sandbox]: DOMTokenList = null; // Private properties #contentWindowContainer: { window: BrowserWindow | CrossOriginBrowserWindow | null } = { window: null }; - #pageLoader: HTMLIFrameElementPageLoader; + #browserFrame: IBrowserFrame; + #browserChildFrame: IBrowserFrame; /** * Constructor. @@ -42,12 +61,16 @@ export default class HTMLIFrameElement extends HTMLElement { */ constructor(browserFrame: IBrowserFrame) { super(); - this.#pageLoader = new HTMLIFrameElementPageLoader({ - element: this, - contentWindowContainer: this.#contentWindowContainer, - browserParentFrame: browserFrame - }); - this[PropertySymbol.attributes] = new HTMLIFrameElementNamedNodeMap(this, this.#pageLoader); + this.#browserFrame = browserFrame; + + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); } /** @@ -223,25 +246,157 @@ export default class HTMLIFrameElement extends HTMLElement { /** * @override */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const isConnected = this[PropertySymbol.isConnected]; - const isParentConnected = parentNode ? parentNode[PropertySymbol.isConnected] : false; + public override [PropertySymbol.connectedToDocument](): void { + super[PropertySymbol.connectedToDocument](); + this.#loadPage(); + } + + /** + * Called when disconnected from document. + * @param e + */ + public [PropertySymbol.disconnectedFromDocument](): void { + super[PropertySymbol.disconnectedFromDocument](); + this.#unloadPage(); + } - super[PropertySymbol.connectToNode](parentNode); + /** + * @override + */ + public override [PropertySymbol.cloneNode](deep = false): HTMLIFrameElement { + return super[PropertySymbol.cloneNode](deep); + } - if (isConnected !== isParentConnected) { - if (isParentConnected) { - this.#pageLoader.loadPage(); + /** + * Triggered when an attribute is set. + * + * @param attribute Attribute. + * @param replacedAttribute Replaced attribute. + */ + #onSetAttribute(attribute: Attr, replacedAttribute: Attr | null): void { + if ( + attribute[PropertySymbol.name] === 'src' && + attribute[PropertySymbol.value] && + attribute[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value] + ) { + this.#loadPage(); + } + + if (attribute[PropertySymbol.name] === 'sandbox') { + if (!this[PropertySymbol.sandbox]) { + this[PropertySymbol.sandbox] = new DOMTokenList(this, 'sandbox'); } else { - this.#pageLoader.unloadPage(); + this[PropertySymbol.sandbox][PropertySymbol.updateIndices](); } + + this.#validateSandboxFlags(); } } /** - * @override + * Triggered when an attribute is removed. */ - public override [PropertySymbol.cloneNode](deep = false): HTMLIFrameElement { - return super[PropertySymbol.cloneNode](deep); + #onRemoveAttribute(): void { + this.#unloadPage(); + } + + /** + * + * @param tokens + * @param vconsole + */ + #validateSandboxFlags(): void { + const window = this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; + const values = this[PropertySymbol.sandbox].values(); + const invalidFlags: string[] = []; + + for (const token of values) { + if (!SANDBOX_FLAGS.includes(token)) { + invalidFlags.push(token); + } + } + + if (invalidFlags.length === 1) { + window.console.error( + `Error while parsing the 'sandbox' attribute: '${invalidFlags[0]}' is an invalid sandbox flag.` + ); + } else if (invalidFlags.length > 1) { + window.console.error( + `Error while parsing the 'sandbox' attribute: '${invalidFlags.join( + `', '` + )}' are invalid sandbox flags.` + ); + } + } + + /** + * Loads an iframe page. + */ + #loadPage(): void { + if (!this[PropertySymbol.isConnected]) { + if (this.#browserChildFrame) { + BrowserFrameFactory.destroyFrame(this.#browserChildFrame); + this.#browserChildFrame = null; + } + this.#contentWindowContainer.window = null; + return; + } + + const window = this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; + const originURL = this.#browserFrame.window.location; + const targetURL = BrowserFrameURL.getRelativeURL(this.#browserFrame, this.src); + + if ( + this.#browserChildFrame && + this.#browserChildFrame.window.location.href === targetURL.href + ) { + return; + } + + if (this.#browserFrame.page.context.browser.settings.disableIframePageLoading) { + WindowErrorUtility.dispatchError( + this, + new DOMException( + `Failed to load iframe page "${targetURL.href}". Iframe page loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ) + ); + return; + } + + // Iframes has a special rule for CORS and doesn't allow access between frames when the origin is different. + const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null'; + const parentWindow = isSameOrigin ? window : new CrossOriginBrowserWindow(window); + + this.#browserChildFrame = + this.#browserChildFrame ?? BrowserFrameFactory.createChildFrame(this.#browserFrame); + + ((this.#browserChildFrame.window.top)) = + parentWindow; + ((this.#browserChildFrame.window.parent)) = + parentWindow; + + this.#browserChildFrame + .goto(targetURL.href, { + referrer: originURL.origin, + referrerPolicy: this.referrerPolicy + }) + .then(() => this.dispatchEvent(new Event('load'))) + .catch((error) => WindowErrorUtility.dispatchError(this, error)); + + this.#contentWindowContainer.window = isSameOrigin + ? this.#browserChildFrame.window + : new CrossOriginBrowserWindow(this.#browserChildFrame.window, window); + } + + /** + * Unloads an iframe page. + */ + #unloadPage(): void { + if (this.#browserChildFrame) { + BrowserFrameFactory.destroyFrame(this.#browserChildFrame); + this.#browserChildFrame = null; + } + this.#contentWindowContainer.window = null; } } diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts deleted file mode 100644 index 7e8013ad..00000000 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementNamedNodeMap.ts +++ /dev/null @@ -1,102 +0,0 @@ -import Attr from '../attr/Attr.js'; -import Element from '../element/Element.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLIFrameElementPageLoader from './HTMLIFrameElementPageLoader.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; - -const SANDBOX_FLAGS = [ - 'allow-downloads', - 'allow-forms', - 'allow-modals', - 'allow-orientation-lock', - 'allow-pointer-lock', - 'allow-popups', - 'allow-popups-to-escape-sandbox', - 'allow-presentation', - 'allow-same-origin', - 'allow-scripts', - 'allow-top-navigation', - 'allow-top-navigation-by-user-activation', - 'allow-top-navigation-to-custom-protocols' -]; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLIFrameElementNamedNodeMap extends HTMLElementNamedNodeMap { - #pageLoader: HTMLIFrameElementPageLoader; - - /** - * Constructor. - * - * @param ownerElement Owner element. - * @param pageLoader - */ - constructor(ownerElement: Element, pageLoader: HTMLIFrameElementPageLoader) { - super(ownerElement); - this.#pageLoader = pageLoader; - } - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedAttribute = super[PropertySymbol.setNamedItem](item); - - if ( - item[PropertySymbol.name] === 'src' && - item[PropertySymbol.value] && - item[PropertySymbol.value] !== replacedAttribute?.[PropertySymbol.value] - ) { - this.#pageLoader.loadPage(); - } - - if (item[PropertySymbol.name] === 'sandbox') { - if (!this[PropertySymbol.ownerElement][PropertySymbol.sandbox]) { - this[PropertySymbol.ownerElement][PropertySymbol.sandbox] = new DOMTokenList( - this[PropertySymbol.ownerElement], - 'sandbox' - ); - } else { - this[PropertySymbol.ownerElement][PropertySymbol.sandbox][PropertySymbol.updateIndices](); - } - - this.#validateSandboxFlags(); - } - - return replacedAttribute || null; - } - - /** - * - * @param tokens - * @param vconsole - */ - #validateSandboxFlags(): void { - const window = - this[PropertySymbol.ownerElement][PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; - const values = this[PropertySymbol.ownerElement][PropertySymbol.sandbox].values(); - const invalidFlags: string[] = []; - - for (const token of values) { - if (!SANDBOX_FLAGS.includes(token)) { - invalidFlags.push(token); - } - } - - if (invalidFlags.length === 1) { - window.console.error( - `Error while parsing the 'sandbox' attribute: '${invalidFlags[0]}' is an invalid sandbox flag.` - ); - } else if (invalidFlags.length > 1) { - window.console.error( - `Error while parsing the 'sandbox' attribute: '${invalidFlags.join( - `', '` - )}' are invalid sandbox flags.` - ); - } - } -} diff --git a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts b/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts deleted file mode 100644 index 6783a5f6..00000000 --- a/packages/happy-dom/src/nodes/html-iframe-element/HTMLIFrameElementPageLoader.ts +++ /dev/null @@ -1,109 +0,0 @@ -import Event from '../../event/Event.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import BrowserWindow from '../../window/BrowserWindow.js'; -import CrossOriginBrowserWindow from '../../window/CrossOriginBrowserWindow.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; -import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; -import HTMLIFrameElement from './HTMLIFrameElement.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import BrowserFrameURL from '../../browser/utilities/BrowserFrameURL.js'; -import BrowserFrameFactory from '../../browser/utilities/BrowserFrameFactory.js'; -import IRequestReferrerPolicy from '../../fetch/types/IRequestReferrerPolicy.js'; - -/** - * HTML Iframe page loader. - */ -export default class HTMLIFrameElementPageLoader { - #element: HTMLIFrameElement; - #contentWindowContainer: { window: BrowserWindow | CrossOriginBrowserWindow | null }; - #browserParentFrame: IBrowserFrame; - #browserIFrame: IBrowserFrame; - - /** - * Constructor. - * - * @param options Options. - * @param options.element Iframe element. - * @param options.browserParentFrame Main browser frame. - * @param options.contentWindowContainer Content window container. - * @param options.contentWindowContainer.window Content window. - */ - constructor(options: { - element: HTMLIFrameElement; - browserParentFrame: IBrowserFrame; - contentWindowContainer: { window: BrowserWindow | CrossOriginBrowserWindow | null }; - }) { - this.#element = options.element; - this.#contentWindowContainer = options.contentWindowContainer; - this.#browserParentFrame = options.browserParentFrame; - } - - /** - * Loads an iframe page. - */ - public loadPage(): void { - if (!this.#element[PropertySymbol.isConnected]) { - if (this.#browserIFrame) { - BrowserFrameFactory.destroyFrame(this.#browserIFrame); - this.#browserIFrame = null; - } - this.#contentWindowContainer.window = null; - return; - } - - const window = this.#element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; - const originURL = this.#browserParentFrame.window.location; - const targetURL = BrowserFrameURL.getRelativeURL(this.#browserParentFrame, this.#element.src); - - if (this.#browserIFrame && this.#browserIFrame.window.location.href === targetURL.href) { - return; - } - - if (this.#browserParentFrame.page.context.browser.settings.disableIframePageLoading) { - WindowErrorUtility.dispatchError( - this.#element, - new DOMException( - `Failed to load iframe page "${targetURL.href}". Iframe page loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ) - ); - return; - } - - // Iframes has a special rule for CORS and doesn't allow access between frames when the origin is different. - const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null'; - const parentWindow = isSameOrigin ? window : new CrossOriginBrowserWindow(window); - - this.#browserIFrame = - this.#browserIFrame ?? BrowserFrameFactory.createChildFrame(this.#browserParentFrame); - - ((this.#browserIFrame.window.top)) = - parentWindow; - ((this.#browserIFrame.window.parent)) = - parentWindow; - - this.#browserIFrame - .goto(targetURL.href, { - referrer: originURL.origin, - referrerPolicy: this.#element.referrerPolicy - }) - .then(() => this.#element.dispatchEvent(new Event('load'))) - .catch((error) => WindowErrorUtility.dispatchError(this.#element, error)); - - this.#contentWindowContainer.window = isSameOrigin - ? this.#browserIFrame.window - : new CrossOriginBrowserWindow(this.#browserIFrame.window, window); - } - - /** - * Unloads an iframe page. - */ - public unloadPage(): void { - if (this.#browserIFrame) { - BrowserFrameFactory.destroyFrame(this.#browserIFrame); - this.#browserIFrame = null; - } - this.#contentWindowContainer.window = null; - } -} 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 39c0a4ee..265201b6 100644 --- a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts +++ b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts @@ -16,13 +16,13 @@ import HTMLLabelElement from '../html-label-element/HTMLLabelElement.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; import HTMLInputElementDateUtility from './HTMLInputElementDateUtility.js'; import HTMLLabelElementUtility from '../html-label-element/HTMLLabelElementUtility.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLInputElementNamedNodeMap from './HTMLInputElementNamedNodeMap.js'; import PointerEvent from '../../event/events/PointerEvent.js'; import { URL } from 'url'; import HTMLDataListElement from '../html-data-list-element/HTMLDataListElement.js'; import Document from '../document/Document.js'; import ShadowRoot from '../shadow-root/ShadowRoot.js'; +import HTMLFieldSetElement from '../html-field-set-element/HTMLFieldSetElement.js'; +import Attr from '../attr/Attr.js'; /** * HTML Input Element. @@ -42,10 +42,6 @@ export default class HTMLInputElement extends HTMLElement { public oninvalid: (event: Event) => void | null = null; public onselectionchange: (event: Event) => void | null = null; - // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLInputElementNamedNodeMap( - this - ); public [PropertySymbol.value] = null; public [PropertySymbol.height] = 0; public [PropertySymbol.width] = 0; @@ -54,6 +50,7 @@ export default class HTMLInputElement extends HTMLElement { public [PropertySymbol.validationMessage] = ''; public [PropertySymbol.validity] = new ValidityState(this); public [PropertySymbol.files]: FileList = new FileList(); + public [PropertySymbol.formNode]: HTMLFormElement | null = null; // Private properties #selectionStart: number = null; @@ -61,6 +58,21 @@ export default class HTMLInputElement extends HTMLElement { #selectionDirection: HTMLInputElementSelectionDirectionEnum = HTMLInputElementSelectionDirectionEnum.none; + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + /** * Returns default checked. * @@ -207,17 +219,19 @@ export default class HTMLInputElement extends HTMLElement { * * @returns Form. */ - public get form(): HTMLFormElement | null { - if (this[PropertySymbol.formNode]) { - return this[PropertySymbol.formNode]; - } - if (!this.isConnected) { - return null; - } + public get form(): HTMLFormElement { const formID = this.getAttribute('form'); - return formID - ? this[PropertySymbol.ownerDocument].getElementById(formID) - : null; + + if (formID !== null) { + if (!this[PropertySymbol.isConnected]) { + return null; + } + return formID + ? (this[PropertySymbol.rootNode]).getElementById(formID) + : null; + } + + return this[PropertySymbol.formNode]; } /** @@ -1394,32 +1408,6 @@ export default class HTMLInputElement extends HTMLElement { return returnValue; } - /** - * @override - */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const oldFormNode = this[PropertySymbol.formNode]; - - super[PropertySymbol.connectToNode](parentNode); - - if (oldFormNode !== this[PropertySymbol.formNode]) { - if (oldFormNode) { - oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); - oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); - } - if (this[PropertySymbol.formNode]) { - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.name - ); - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.id - ); - } - } - } - /** * Checks is selection is supported. * diff --git a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts deleted file mode 100644 index 8d2b3a8b..00000000 --- a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElementNamedNodeMap.ts +++ /dev/null @@ -1,58 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; -import HTMLInputElement from './HTMLInputElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLInputElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLInputElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - if (replacedItem && replacedItem[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); - } - if (item[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.appendFormControlItem - ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts index 74833b03..9c5bc135 100644 --- a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts +++ b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElement.ts @@ -4,6 +4,8 @@ import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import Event from '../../event/Event.js'; import EventPhaseEnum from '../../event/EventPhaseEnum.js'; import PointerEvent from '../../event/events/PointerEvent.js'; +import HTMLInputElement from '../html-input-element/HTMLInputElement.js'; +import Document from '../document/Document.js'; /** * HTML Label Element. @@ -42,13 +44,31 @@ export default class HTMLLabelElement extends HTMLElement { * * @returns Control element. */ - public get control(): HTMLElement { - const htmlFor = this.htmlFor; - if (htmlFor) { - const control = this[PropertySymbol.ownerDocument].getElementById(htmlFor); - return control !== this ? control : null; + public get control(): HTMLElement | null { + const htmlFor = this.getAttribute('for'); + if (htmlFor !== null) { + if (!htmlFor || !this[PropertySymbol.isConnected]) { + return null; + } + const control = ( + (this[PropertySymbol.rootNode]).getElementById(htmlFor) + ); + switch (control.tagName) { + case 'input': + return (control).type !== 'hidden' ? control : null; + case 'button': + case 'meter': + case 'output': + case 'progress': + case 'select': + case 'textarea': + case 'textarea': + return control; + default: + return null; + } } - return ( + return ( this.querySelector('button,input:not([type="hidden"]),meter,output,progress,select,textarea') ); } @@ -58,8 +78,8 @@ export default class HTMLLabelElement extends HTMLElement { * * @returns Form. */ - public get form(): HTMLFormElement { - return this[PropertySymbol.formNode]; + public get form(): HTMLFormElement | null { + return (this.control)?.form || null; } /** diff --git a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts index 59e71929..e42208ce 100644 --- a/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts +++ b/packages/happy-dom/src/nodes/html-label-element/HTMLLabelElementUtility.ts @@ -18,7 +18,7 @@ export default class HTMLLabelElementUtility { public static getAssociatedLabelElements(element: HTMLElement): NodeList { const id = element.id; let labels: NodeList; - if (id) { + if (id && element[PropertySymbol.isConnected]) { const rootNode = element[PropertySymbol.rootNode] || element[PropertySymbol.ownerDocument]; diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts index 8d0bc357..fe9c4c5c 100644 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts +++ b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElement.ts @@ -5,10 +5,13 @@ import Event from '../../event/Event.js'; import ErrorEvent from '../../event/events/ErrorEvent.js'; import Node from '../../nodes/node/Node.js'; import DOMTokenList from '../../dom-token-list/DOMTokenList.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLLinkElementNamedNodeMap from './HTMLLinkElementNamedNodeMap.js'; -import HTMLLinkElementStyleSheetLoader from './HTMLLinkElementStyleSheetLoader.js'; import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; +import Attr from '../attr/Attr.js'; +import WindowErrorUtility from '../../window/WindowErrorUtility.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import ResourceFetch from '../../fetch/ResourceFetch.js'; +import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; /** * HTML Link Element. @@ -22,11 +25,11 @@ export default class HTMLLinkElement extends HTMLElement { public onload: (event: Event) => void = null; // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap; public [PropertySymbol.sheet]: CSSStyleSheet = null; public [PropertySymbol.evaluateCSS] = true; public [PropertySymbol.relList]: DOMTokenList = null; - #styleSheetLoader: HTMLLinkElementStyleSheetLoader; + #loadedStyleSheetURL: string | null = null; + #browserFrame: IBrowserFrame; /** * Constructor. @@ -36,12 +39,15 @@ export default class HTMLLinkElement extends HTMLElement { constructor(browserFrame: IBrowserFrame) { super(); - this.#styleSheetLoader = new HTMLLinkElementStyleSheetLoader({ - element: this, - browserFrame - }); - - this[PropertySymbol.attributes] = new HTMLLinkElementNamedNodeMap(this, this.#styleSheetLoader); + this.#browserFrame = browserFrame; + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); } /** @@ -219,18 +225,111 @@ export default class HTMLLinkElement extends HTMLElement { /** * @override */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const isConnected = this[PropertySymbol.isConnected]; - const isParentConnected = parentNode ? parentNode[PropertySymbol.isConnected] : false; + public override [PropertySymbol.connectedToDocument](): void { + super[PropertySymbol.connectedToDocument](); + if (this[PropertySymbol.evaluateCSS]) { + this.#loadStyleSheet(this.getAttribute('href'), this.getAttribute('rel')); + } + } + + /** + * Triggered when an attribute is set. + * + * @param item Item + */ + #onSetAttribute(item: Attr): void { + if (item[PropertySymbol.name] === 'rel' && this[PropertySymbol.relList]) { + this[PropertySymbol.relList][PropertySymbol.updateIndices](); + } + + if (item[PropertySymbol.name] === 'rel') { + this.#loadStyleSheet(this.getAttribute('href'), item[PropertySymbol.value]); + } else if (item[PropertySymbol.name] === 'href') { + this.#loadStyleSheet(item[PropertySymbol.value], this.getAttribute('rel')); + } + } + + /** + * Triggered when an attribute is removed. + * + * @param removedItem Removed item. + */ + #onRemoveAttribute(removedItem: Attr): void { + if (removedItem[PropertySymbol.name] === 'rel' && this[PropertySymbol.relList]) { + this[PropertySymbol.relList][PropertySymbol.updateIndices](); + } + } + + /** + * Returns a URL relative to the given Location object. + * + * @param url URL. + * @param rel Rel. + */ + async #loadStyleSheet(url: string | null, rel: string | null): Promise { + const browserSettings = this.#browserFrame.page.context.browser.settings; + const window = this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; + + if (!url || !rel || rel.toLowerCase() !== 'stylesheet' || !this[PropertySymbol.isConnected]) { + return; + } + + let absoluteURL: string; + try { + absoluteURL = new URL(url, window.location.href).href; + } catch (error) { + return; + } + + if (this.#loadedStyleSheetURL === absoluteURL) { + return; + } + + if (browserSettings.disableCSSFileLoading) { + if (browserSettings.handleDisabledFileLoadingAsSuccess) { + this.dispatchEvent(new Event('load')); + } else { + WindowErrorUtility.dispatchError( + this, + new DOMException( + `Failed to load external stylesheet "${absoluteURL}". CSS file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ) + ); + } + return; + } + + const resourceFetch = new ResourceFetch({ + browserFrame: this.#browserFrame, + window: window + }); + const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>( + (window) + ))[PropertySymbol.readyStateManager]; + + this.#loadedStyleSheetURL = absoluteURL; + + readyStateManager.startTask(); + + let code: string | null = null; + let error: Error | null = null; + + try { + code = await resourceFetch.fetch(absoluteURL); + } catch (e) { + error = e; + } - super[PropertySymbol.connectToNode](parentNode); + readyStateManager.endTask(); - if ( - isParentConnected && - isConnected !== isParentConnected && - this[PropertySymbol.evaluateCSS] - ) { - this.#styleSheetLoader.loadStyleSheet(this.getAttribute('href'), this.getAttribute('rel')); + if (error) { + WindowErrorUtility.dispatchError(this, error); + } else { + const styleSheet = new CSSStyleSheet(); + styleSheet.replaceSync(code); + this[PropertySymbol.sheet] = styleSheet; + this.dispatchEvent(new Event('load')); } } } diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts deleted file mode 100644 index 063ef4f4..00000000 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementNamedNodeMap.ts +++ /dev/null @@ -1,72 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLLinkElement from './HTMLLinkElement.js'; -import HTMLLinkElementStyleSheetLoader from './HTMLLinkElementStyleSheetLoader.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLLinkElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLLinkElement; - #styleSheetLoader: HTMLLinkElementStyleSheetLoader; - - /** - * Constructor. - * - * @param ownerElement Owner element. - * @param stylesheetLoader Stylesheet loader. - * @param styleSheetLoader - */ - constructor(ownerElement: HTMLLinkElement, styleSheetLoader: HTMLLinkElementStyleSheetLoader) { - super(ownerElement); - this.#styleSheetLoader = styleSheetLoader; - } - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - item[PropertySymbol.name] === 'rel' && - this[PropertySymbol.ownerElement][PropertySymbol.relList] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); - } - - if (item[PropertySymbol.name] === 'rel') { - this.#styleSheetLoader.loadStyleSheet( - this[PropertySymbol.ownerElement].getAttribute('href'), - item[PropertySymbol.value] - ); - } else if (item[PropertySymbol.name] === 'href') { - this.#styleSheetLoader.loadStyleSheet( - item[PropertySymbol.value], - this[PropertySymbol.ownerElement].getAttribute('rel') - ); - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - removedItem[PropertySymbol.name] === 'rel' && - this[PropertySymbol.ownerElement][PropertySymbol.relList] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.relList][PropertySymbol.updateIndices](); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementStyleSheetLoader.ts b/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementStyleSheetLoader.ts deleted file mode 100644 index d74bb704..00000000 --- a/packages/happy-dom/src/nodes/html-link-element/HTMLLinkElementStyleSheetLoader.ts +++ /dev/null @@ -1,110 +0,0 @@ -import Event from '../../event/Event.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import ResourceFetch from '../../fetch/ResourceFetch.js'; -import CSSStyleSheet from '../../css/CSSStyleSheet.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; -import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; -import HTMLLinkElement from './HTMLLinkElement.js'; -import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; - -/** - * Helper class for getting the URL relative to a Location object. - */ -export default class HTMLLinkElementStyleSheetLoader { - #element: HTMLLinkElement; - #browserFrame: IBrowserFrame; - #loadedStyleSheetURL: string | null = null; - - /** - * Constructor. - * - * @param options Options. - * @param options.element Element. - * @param options.browserFrame Browser frame. - */ - constructor(options: { element: HTMLLinkElement; browserFrame: IBrowserFrame }) { - this.#element = options.element; - this.#browserFrame = options.browserFrame; - } - - /** - * Returns a URL relative to the given Location object. - * - * @param url URL. - * @param rel Rel. - */ - public async loadStyleSheet(url: string | null, rel: string | null): Promise { - const element = this.#element; - const browserSettings = this.#browserFrame.page.context.browser.settings; - const window = element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]; - - if ( - !url || - !rel || - rel.toLowerCase() !== 'stylesheet' || - !element[PropertySymbol.isConnected] - ) { - return; - } - - let absoluteURL: string; - try { - absoluteURL = new URL(url, window.location.href).href; - } catch (error) { - return; - } - - if (this.#loadedStyleSheetURL === absoluteURL) { - return; - } - - if (browserSettings.disableCSSFileLoading) { - if (browserSettings.handleDisabledFileLoadingAsSuccess) { - element.dispatchEvent(new Event('load')); - } else { - WindowErrorUtility.dispatchError( - element, - new DOMException( - `Failed to load external stylesheet "${absoluteURL}". CSS file loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ) - ); - } - return; - } - - const resourceFetch = new ResourceFetch({ - browserFrame: this.#browserFrame, - window: window - }); - const readyStateManager = (<{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }>( - (window) - ))[PropertySymbol.readyStateManager]; - - this.#loadedStyleSheetURL = absoluteURL; - - readyStateManager.startTask(); - - let code: string | null = null; - let error: Error | null = null; - - try { - code = await resourceFetch.fetch(absoluteURL); - } catch (e) { - error = e; - } - - readyStateManager.endTask(); - - if (error) { - WindowErrorUtility.dispatchError(element, error); - } else { - const styleSheet = new CSSStyleSheet(); - styleSheet.replaceSync(code); - element[PropertySymbol.sheet] = styleSheet; - element.dispatchEvent(new Event('load')); - } - } -} diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts index 8afede91..5b3dbb8a 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts @@ -1,11 +1,10 @@ -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; import * as PropertySymbol from '../../PropertySymbol.js'; +import Attr from '../attr/Attr.js'; import HTMLDataListElement from '../html-data-list-element/HTMLDataListElement.js'; import HTMLElement from '../html-element/HTMLElement.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; import Node from '../node/Node.js'; -import HTMLOptionElementNamedNodeMap from './HTMLOptionElementNamedNodeMap.js'; /** * HTML Option Element. @@ -14,11 +13,24 @@ import HTMLOptionElementNamedNodeMap from './HTMLOptionElementNamedNodeMap.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionElement. */ export default class HTMLOptionElement extends HTMLElement { - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLOptionElementNamedNodeMap( - this - ); public [PropertySymbol.selectedness] = false; public [PropertySymbol.dirtyness] = false; + public [PropertySymbol.selectNode]: HTMLSelectElement | null = null; + + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } /** * Returns inner text, which is the rendered appearance of text. @@ -55,7 +67,7 @@ export default class HTMLOptionElement extends HTMLElement { * @returns Form. */ public get form(): HTMLFormElement { - return this[PropertySymbol.formNode]; + return (this[PropertySymbol.selectNode])?.form; } /** @@ -126,11 +138,11 @@ export default class HTMLOptionElement extends HTMLElement { /** * @override */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { + public override [PropertySymbol.connectedToDocument](parentNode: Node = null): void { const oldSelectNode = this[PropertySymbol.selectNode]; const oldDataListNode = this[PropertySymbol.dataListNode]; - super[PropertySymbol.connectToNode](parentNode); + super[PropertySymbol.connectedToDocument](parentNode); const selectNode = this[PropertySymbol.selectNode]; @@ -164,4 +176,47 @@ export default class HTMLOptionElement extends HTMLElement { } } } + + /** + * Triggered when an attribute is set. + * + * @param attribute Attribute. + * @param replacedAttribute Replaced attribute. + */ + #onSetAttribute(attribute: Attr, replacedAttribute: Attr | null): void { + if ( + !this[PropertySymbol.dirtyness] && + attribute[PropertySymbol.name] === 'selected' && + replacedAttribute?.[PropertySymbol.value] !== attribute[PropertySymbol.value] + ) { + const selectNode = this[PropertySymbol.selectNode]; + + this[PropertySymbol.selectedness] = true; + + if (selectNode) { + selectNode[PropertySymbol.updateOptionItems](this); + } + } + } + + /** + * Triggered when an attribute is removed. + * + * @param removedAttribute Removed attribute. + */ + #onRemoveAttribute(removedAttribute: Attr): void { + if ( + removedAttribute && + !this[PropertySymbol.dirtyness] && + removedAttribute[PropertySymbol.name] === 'selected' + ) { + const selectNode = this[PropertySymbol.selectNode]; + + this[PropertySymbol.selectedness] = false; + + if (selectNode) { + selectNode[PropertySymbol.updateOptionItems](); + } + } + } } diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts deleted file mode 100644 index 7446a6e5..00000000 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementNamedNodeMap.ts +++ /dev/null @@ -1,64 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js'; -import HTMLOptionElement from './HTMLOptionElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLOptionElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLOptionElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - !this[PropertySymbol.ownerElement][PropertySymbol.dirtyness] && - item[PropertySymbol.name] === 'selected' && - replacedItem?.[PropertySymbol.value] !== item[PropertySymbol.value] - ) { - const selectNode = ( - this[PropertySymbol.ownerElement][PropertySymbol.selectNode] - ); - - this[PropertySymbol.ownerElement][PropertySymbol.selectedness] = true; - - if (selectNode) { - selectNode[PropertySymbol.updateOptionItems](this[PropertySymbol.ownerElement]); - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - !this[PropertySymbol.ownerElement][PropertySymbol.dirtyness] && - removedItem[PropertySymbol.name] === 'selected' - ) { - const selectNode = ( - this[PropertySymbol.ownerElement][PropertySymbol.selectNode] - ); - - this[PropertySymbol.ownerElement][PropertySymbol.selectedness] = false; - - if (selectNode) { - selectNode[PropertySymbol.updateOptionItems](); - } - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts index aa2ca738..17b34b9e 100644 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts @@ -3,13 +3,15 @@ import * as PropertySymbol from '../../PropertySymbol.js'; import Event from '../../event/Event.js'; import ErrorEvent from '../../event/events/ErrorEvent.js'; import Node from '../../nodes/node/Node.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLScriptElementNamedNodeMap from './HTMLScriptElementNamedNodeMap.js'; import WindowErrorUtility from '../../window/WindowErrorUtility.js'; import WindowBrowserSettingsReader from '../../window/WindowBrowserSettingsReader.js'; -import HTMLScriptElementScriptLoader from './HTMLScriptElementScriptLoader.js'; import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; +import Attr from '../attr/Attr.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import ResourceFetch from '../../fetch/ResourceFetch.js'; +import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; /** * HTML Script Element. @@ -26,11 +28,11 @@ export default class HTMLScriptElement extends HTMLElement { public onload: (event: Event) => void = null; // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap; public [PropertySymbol.evaluateScript] = true; // Private properties - #scriptLoader: HTMLScriptElementScriptLoader; + #browserFrame: IBrowserFrame; + #loadedScriptURL: string | null = null; /** * Constructor. @@ -40,12 +42,11 @@ export default class HTMLScriptElement extends HTMLElement { constructor(browserFrame: IBrowserFrame) { super(); - this.#scriptLoader = new HTMLScriptElementScriptLoader({ - element: this, - browserFrame - }); - - this[PropertySymbol.attributes] = new HTMLScriptElementNamedNodeMap(this, this.#scriptLoader); + this.#browserFrame = browserFrame; + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); } /** @@ -201,24 +202,18 @@ export default class HTMLScriptElement extends HTMLElement { /** * @override */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const isConnected = this[PropertySymbol.isConnected]; - const isParentConnected = parentNode ? parentNode[PropertySymbol.isConnected] : false; + public override [PropertySymbol.connectedToDocument](): void { const browserSettings = WindowBrowserSettingsReader.getSettings( this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] ); - super[PropertySymbol.connectToNode](parentNode); + super[PropertySymbol.connectedToDocument](); - if ( - isParentConnected && - isConnected !== isParentConnected && - this[PropertySymbol.evaluateScript] - ) { + if (this[PropertySymbol.evaluateScript]) { const src = this.getAttribute('src'); if (src !== null) { - this.#scriptLoader.loadScript(src); + this.#loadScript(src); } else if (!browserSettings.disableJavaScriptEvaluation) { const textContent = this.textContent; const type = this.getAttribute('type'); @@ -253,4 +248,119 @@ export default class HTMLScriptElement extends HTMLElement { } } } + + /** + * Triggered when an attribute is set. + * + * @param item Item + */ + #onSetAttribute(item: Attr): void { + if ( + item[PropertySymbol.name] === 'src' && + item[PropertySymbol.value] !== null && + this[PropertySymbol.isConnected] + ) { + this.#loadScript(item[PropertySymbol.value]); + } + } + + /** + * Returns a URL relative to the given Location object. + * + * @param url URL. + */ + async #loadScript(url: string): Promise { + const browserSettings = this.#browserFrame.page.context.browser.settings; + const async = this.getAttribute('async') !== null; + + if (!url || !this[PropertySymbol.isConnected]) { + return; + } + + let absoluteURL: string; + try { + absoluteURL = new URL( + url, + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location.href + ).href; + } catch (error) { + return; + } + + if (this.#loadedScriptURL === absoluteURL) { + return; + } + + if ( + browserSettings.disableJavaScriptFileLoading || + browserSettings.disableJavaScriptEvaluation + ) { + if (browserSettings.handleDisabledFileLoadingAsSuccess) { + this.dispatchEvent(new Event('load')); + } else { + WindowErrorUtility.dispatchError( + this, + new DOMException( + `Failed to load external script "${absoluteURL}". JavaScript file loading is disabled.`, + DOMExceptionNameEnum.notSupportedError + ) + ); + } + return; + } + + const resourceFetch = new ResourceFetch({ + browserFrame: this.#browserFrame, + window: this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] + }); + let code: string | null = null; + let error: Error | null = null; + + this.#loadedScriptURL = absoluteURL; + + if (async) { + const readyStateManager = (< + { [PropertySymbol.readyStateManager]: DocumentReadyStateManager } + >(this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]))[ + PropertySymbol.readyStateManager + ]; + + readyStateManager.startTask(); + + try { + code = await resourceFetch.fetch(absoluteURL); + } catch (e) { + error = e; + } + + readyStateManager.endTask(); + } else { + try { + code = resourceFetch.fetchSync(absoluteURL); + } catch (e) { + error = e; + } + } + + if (error) { + WindowErrorUtility.dispatchError(this, error); + } else { + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this; + code = '//# sourceURL=' + absoluteURL + '\n' + code; + + if ( + browserSettings.disableErrorCapturing || + browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch + ) { + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code); + } else { + WindowErrorUtility.captureError( + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow], + () => this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code) + ); + } + this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; + this.dispatchEvent(new Event('load')); + } + } } diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts deleted file mode 100644 index a9fa2c5a..00000000 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementNamedNodeMap.ts +++ /dev/null @@ -1,43 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLScriptElement from './HTMLScriptElement.js'; -import HTMLScriptElementScriptLoader from './HTMLScriptElementScriptLoader.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLScriptElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLScriptElement; - #scriptLoader: HTMLScriptElementScriptLoader; - - /** - * Constructor. - * - * @param ownerElement Owner element. - * @param scriptLoader Script loader. - */ - constructor(ownerElement: HTMLScriptElement, scriptLoader: HTMLScriptElementScriptLoader) { - super(ownerElement); - this.#scriptLoader = scriptLoader; - } - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - item[PropertySymbol.name] === 'src' && - item[PropertySymbol.value] !== null && - this[PropertySymbol.ownerElement][PropertySymbol.isConnected] - ) { - this.#scriptLoader.loadScript(item[PropertySymbol.value]); - } - - return replacedItem || null; - } -} diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementScriptLoader.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementScriptLoader.ts deleted file mode 100644 index 1cc43690..00000000 --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElementScriptLoader.ts +++ /dev/null @@ -1,132 +0,0 @@ -import Event from '../../event/Event.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import ResourceFetch from '../../fetch/ResourceFetch.js'; -import WindowErrorUtility from '../../window/WindowErrorUtility.js'; -import DocumentReadyStateManager from '../document/DocumentReadyStateManager.js'; -import HTMLScriptElement from './HTMLScriptElement.js'; -import IBrowserFrame from '../../browser/types/IBrowserFrame.js'; -import BrowserErrorCaptureEnum from '../../browser/enums/BrowserErrorCaptureEnum.js'; - -/** - * Helper class for getting the URL relative to a Location object. - */ -export default class HTMLScriptElementScriptLoader { - #element: HTMLScriptElement; - #browserFrame: IBrowserFrame; - #loadedScriptURL: string | null = null; - - /** - * Constructor. - * - * @param options Options. - * @param options.element Element. - * @param options.browserFrame Browser frame. - */ - constructor(options: { element: HTMLScriptElement; browserFrame: IBrowserFrame }) { - this.#element = options.element; - this.#browserFrame = options.browserFrame; - } - - /** - * Returns a URL relative to the given Location object. - * - * @param url URL. - */ - public async loadScript(url: string): Promise { - const browserSettings = this.#browserFrame.page.context.browser.settings; - const element = this.#element; - const async = element.getAttribute('async') !== null; - - if (!url || !element[PropertySymbol.isConnected]) { - return; - } - - let absoluteURL: string; - try { - absoluteURL = new URL( - url, - element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].location.href - ).href; - } catch (error) { - return; - } - - if (this.#loadedScriptURL === absoluteURL) { - return; - } - - if ( - browserSettings.disableJavaScriptFileLoading || - browserSettings.disableJavaScriptEvaluation - ) { - if (browserSettings.handleDisabledFileLoadingAsSuccess) { - element.dispatchEvent(new Event('load')); - } else { - WindowErrorUtility.dispatchError( - element, - new DOMException( - `Failed to load external script "${absoluteURL}". JavaScript file loading is disabled.`, - DOMExceptionNameEnum.notSupportedError - ) - ); - } - return; - } - - const resourceFetch = new ResourceFetch({ - browserFrame: this.#browserFrame, - window: element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow] - }); - let code: string | null = null; - let error: Error | null = null; - - this.#loadedScriptURL = absoluteURL; - - if (async) { - const readyStateManager = (< - { [PropertySymbol.readyStateManager]: DocumentReadyStateManager } - >(element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow]))[ - PropertySymbol.readyStateManager - ]; - - readyStateManager.startTask(); - - try { - code = await resourceFetch.fetch(absoluteURL); - } catch (e) { - error = e; - } - - readyStateManager.endTask(); - } else { - try { - code = resourceFetch.fetchSync(absoluteURL); - } catch (e) { - error = e; - } - } - - if (error) { - WindowErrorUtility.dispatchError(element, error); - } else { - element[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = element; - code = '//# sourceURL=' + absoluteURL + '\n' + code; - - if ( - browserSettings.disableErrorCapturing || - browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch - ) { - element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code); - } else { - WindowErrorUtility.captureError( - element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow], - () => element[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].eval(code) - ); - } - element[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null; - element.dispatchEvent(new Event('load')); - } - } -} diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts index 65495e4d..0b561add 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLOptionsCollection.ts @@ -2,6 +2,8 @@ import DOMException from '../../exception/DOMException.js'; import HTMLCollection from '../element/HTMLCollection.js'; import HTMLSelectElement from './HTMLSelectElement.js'; import HTMLOptionElement from '../html-option-element/HTMLOptionElement.js'; +import Element from '../element/Element.js'; +import { PropertySymbol } from '../../index.js'; /** * HTML Options Collection. @@ -10,6 +12,7 @@ import HTMLOptionElement from '../html-option-element/HTMLOptionElement.js'; * https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionsCollection. */ export default class HTMLOptionsCollection extends HTMLCollection { + #selectedIndex: number | null = null; #selectElement: HTMLSelectElement; /** @@ -17,7 +20,7 @@ export default class HTMLOptionsCollection extends HTMLCollection element[PropertySymbol.tagName] === 'OPTION'); this.#selectElement = selectElement; } @@ -28,7 +31,18 @@ export default class HTMLOptionsCollection extends HTMLCollectionthis[PropertySymbol.options][i])[PropertySymbol.selectedness]) { + this.#selectedIndex = i; + return i; + } + } + this.#selectedIndex = -1; + return -1; } /** @@ -37,7 +51,23 @@ export default class HTMLOptionsCollection extends HTMLCollectionthis[PropertySymbol.options][i])[PropertySymbol.selectedness] = false; + } + + const selectedOption = this[PropertySymbol.options][selectedIndex]; + + if (!selectedOption) { + return; + } + + selectedOption[PropertySymbol.selectedness] = true; + selectedOption[PropertySymbol.dirtyness] = true; + this.#selectedIndex = selectedIndex; } /** @@ -65,11 +95,19 @@ export default class HTMLOptionsCollection extends HTMLCollectionbefore]); + const optionsElement = this[before]; + + if (!optionsElement) { + throw new DOMException( + "Failed to execute 'add' on 'DOMException': The node before which the new node is to be inserted is not a child of this node." + ); + } + + this.#selectElement.insertBefore(element, optionsElement); return; } - const index = this.indexOf(before); + const index = this[PropertySymbol.indexOf](before); if (index === -1) { throw new DOMException( @@ -90,4 +128,40 @@ export default class HTMLOptionsCollection extends HTMLCollectionthis[index]); } } + + /** + * @override + */ + public [PropertySymbol.addItem](item: HTMLOptionElement): boolean { + const returnValue = super[PropertySymbol.addItem](item); + if (returnValue) { + this.#selectedIndex = null; + } + return returnValue; + } + + /** + * @override + */ + public [PropertySymbol.insertItem]( + newItem: HTMLOptionElement, + referenceItem: HTMLOptionElement | null + ): boolean { + const returnValue = super[PropertySymbol.insertItem](newItem, referenceItem); + if (returnValue) { + this.#selectedIndex = null; + } + return returnValue; + } + + /** + * @override + */ + public [PropertySymbol.removeItem](item: HTMLOptionElement): boolean { + const returnValue = super[PropertySymbol.removeItem](item); + if (returnValue) { + this.#selectedIndex = null; + } + return returnValue; + } } diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts index 32696ca2..075b464e 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts @@ -5,14 +5,15 @@ import ValidityState from '../../validity-state/ValidityState.js'; import HTMLLabelElement from '../html-label-element/HTMLLabelElement.js'; import HTMLOptionElement from '../html-option-element/HTMLOptionElement.js'; import HTMLOptionsCollection from './HTMLOptionsCollection.js'; -import NodeList from '../node/NodeList.js'; import Event from '../../event/Event.js'; import Node from '../node/Node.js'; import NodeTypeEnum from '../node/NodeTypeEnum.js'; import HTMLLabelElementUtility from '../html-label-element/HTMLLabelElementUtility.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLSelectElementNamedNodeMap from './HTMLSelectElementNamedNodeMap.js'; import HTMLCollection from '../element/HTMLCollection.js'; +import Document from '../document/Document.js'; +import IHTMLCollection from '../element/IHTMLCollection.js'; +import Element from '../element/Element.js'; +import NodeList from '../node/INodeList.js'; /** * HTML Select Element. @@ -22,19 +23,58 @@ import HTMLCollection from '../element/HTMLCollection.js'; */ export default class HTMLSelectElement extends HTMLElement { // Internal properties. - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLSelectElementNamedNodeMap( - this - ); public [PropertySymbol.validationMessage] = ''; public [PropertySymbol.validity] = new ValidityState(this); public [PropertySymbol.selectNode]: Node = this; public [PropertySymbol.length] = 0; public [PropertySymbol.options]: HTMLOptionsCollection = new HTMLOptionsCollection(this); + public [PropertySymbol.formNode]: HTMLFormElement | null = null; + public [PropertySymbol.selectedOptions]: IHTMLCollection = + new HTMLCollection( + (element: Element) => + element[PropertySymbol.tagName] === 'OPTION' && element[PropertySymbol.selectedness] + ); // Events public onchange: (event: Event) => void | null = null; public oninput: (event: Event) => void | null = null; + /** + * Constructor. + */ + constructor() { + super(); + + // Child nodes listeners + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]('add', (item: Node) => { + (item)[PropertySymbol.selectNode] = this; + this[PropertySymbol.options][PropertySymbol.addItem](item); + this[PropertySymbol.selectedOptions][PropertySymbol.addItem](item); + }); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]( + 'insert', + (newItem: Node, referenceItem: Node | null) => { + (newItem)[PropertySymbol.selectNode] = this; + this[PropertySymbol.options][PropertySymbol.insertItem]( + newItem, + referenceItem + ); + this[PropertySymbol.selectedOptions][PropertySymbol.insertItem]( + newItem, + referenceItem + ); + } + ); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]( + 'remove', + (item: Node) => { + (item)[PropertySymbol.selectNode] = null; + this[PropertySymbol.options][PropertySymbol.removeItem](item); + this[PropertySymbol.selectedOptions][PropertySymbol.removeItem](item); + } + ); + } + /** * Returns length. * @@ -225,12 +265,7 @@ export default class HTMLSelectElement extends HTMLElement { * @returns Value. */ public get selectedIndex(): number { - for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { - if ((this[PropertySymbol.options][i])[PropertySymbol.selectedness]) { - return i; - } - } - return -1; + return this[PropertySymbol.options].selectedIndex; } /** @@ -239,17 +274,7 @@ export default class HTMLSelectElement extends HTMLElement { * @param selectedIndex Selected index. */ public set selectedIndex(selectedIndex: number) { - if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { - for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { - (this[PropertySymbol.options][i])[PropertySymbol.selectedness] = false; - } - - const selectedOption = this[PropertySymbol.options][selectedIndex]; - if (selectedOption) { - selectedOption[PropertySymbol.selectedness] = true; - selectedOption[PropertySymbol.dirtyness] = true; - } - } + this[PropertySymbol.options].selectedIndex = selectedIndex; } /** @@ -257,14 +282,8 @@ export default class HTMLSelectElement extends HTMLElement { * * @returns HTMLCollection. */ - public get selectedOptions(): HTMLCollection { - const selectedOptions = new HTMLCollection(); - for (let i = 0, max = this[PropertySymbol.options].length; i < max; i++) { - if ((this[PropertySymbol.options][i])[PropertySymbol.selectedness]) { - selectedOptions.push(this[PropertySymbol.options][i]); - } - } - return selectedOptions; + public get selectedOptions(): IHTMLCollection { + return this[PropertySymbol.selectedOptions]; } /** @@ -282,6 +301,17 @@ export default class HTMLSelectElement extends HTMLElement { * @returns Form. */ public get form(): HTMLFormElement { + const formID = this.getAttribute('form'); + + if (formID !== null) { + if (!this[PropertySymbol.isConnected]) { + return null; + } + return formID + ? (this[PropertySymbol.rootNode]).getElementById(formID) + : null; + } + return this[PropertySymbol.formNode]; } @@ -437,32 +467,6 @@ export default class HTMLSelectElement extends HTMLElement { } } - /** - * @override - */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const oldFormNode = this[PropertySymbol.formNode]; - - super[PropertySymbol.connectToNode](parentNode); - - if (oldFormNode !== this[PropertySymbol.formNode]) { - if (oldFormNode) { - oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); - oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); - } - if (this[PropertySymbol.formNode]) { - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.name - ); - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.id - ); - } - } - } - /** * Returns display size. * diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts deleted file mode 100644 index 4ed79636..00000000 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementNamedNodeMap.ts +++ /dev/null @@ -1,58 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; -import HTMLSelectElement from './HTMLSelectElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLSelectElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLSelectElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - if (replacedItem && replacedItem[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); - } - if (item[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.appendFormControlItem - ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts b/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts index 791c56be..7472b28f 100644 --- a/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts +++ b/packages/happy-dom/src/nodes/html-slot-element/HTMLSlotElement.ts @@ -89,7 +89,7 @@ export default class HTMLSlotElement extends HTMLElement { if (name) { const assignedElements = []; - for (const child of (host)[PropertySymbol.children]) { + for (const child of (host)[PropertySymbol.children][PropertySymbol.items]) { if (child.slot === name) { assignedElements.push(child); } @@ -98,7 +98,7 @@ export default class HTMLSlotElement extends HTMLElement { return assignedElements; } - return (host)[PropertySymbol.children].slice(); + return (host)[PropertySymbol.children][PropertySymbol.items].slice(); } return []; diff --git a/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts b/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts index 2d0ec33e..d0505274 100644 --- a/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts +++ b/packages/happy-dom/src/nodes/html-style-element/HTMLStyleElement.ts @@ -115,14 +115,19 @@ export default class HTMLStyleElement extends HTMLElement { /** * @override */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - super[PropertySymbol.connectToNode](parentNode); - - if (parentNode) { + public override [PropertySymbol.connectedToDocument](parentNode: Node): void { + super[PropertySymbol.connectedToDocument](parentNode); + if (this[PropertySymbol.isConnected]) { this[PropertySymbol.sheet] = new CSSStyleSheet(); this[PropertySymbol.sheet].replaceSync(this.textContent); - } else { - this[PropertySymbol.sheet] = null; } } + + /** + * @override + */ + public override [PropertySymbol.disconnectedFromDocument](parentNode: Node): void { + super[PropertySymbol.disconnectedFromDocument](parentNode); + this[PropertySymbol.sheet] = null; + } } diff --git a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts index 8fc7ccc3..bfd481af 100644 --- a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts +++ b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElement.ts @@ -6,13 +6,13 @@ import HTMLElement from '../html-element/HTMLElement.js'; import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; import HTMLInputElementSelectionDirectionEnum from '../html-input-element/HTMLInputElementSelectionDirectionEnum.js'; import HTMLInputElementSelectionModeEnum from '../html-input-element/HTMLInputElementSelectionModeEnum.js'; -import Node from '../node/Node.js'; import ValidityState from '../../validity-state/ValidityState.js'; import NodeList from '../node/NodeList.js'; import HTMLLabelElement from '../html-label-element/HTMLLabelElement.js'; import HTMLLabelElementUtility from '../html-label-element/HTMLLabelElementUtility.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import HTMLTextAreaElementNamedNodeMap from './HTMLTextAreaElementNamedNodeMap.js'; +import Document from '../document/Document.js'; +import Text from '../text/Text.js'; +import Node from '../node/Node.js'; /** * HTML Text Area Element. @@ -30,19 +30,45 @@ export default class HTMLTextAreaElement extends HTMLElement { public onselectionchange: (event: Event) => void | null = null; // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap = new HTMLTextAreaElementNamedNodeMap( - this - ); public [PropertySymbol.validationMessage] = ''; public [PropertySymbol.validity] = new ValidityState(this); public [PropertySymbol.value] = null; public [PropertySymbol.textAreaNode]: HTMLTextAreaElement = this; + public [PropertySymbol.formNode]: HTMLFormElement | null = null; // Private properties #selectionStart = null; #selectionEnd = null; #selectionDirection = HTMLInputElementSelectionDirectionEnum.none; + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]('add', (item: Node) => { + if (item instanceof Text) { + this[PropertySymbol.resetSelection](); + } + }); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]( + 'insert', + (newItem: Node) => { + if (newItem instanceof Text) { + this[PropertySymbol.resetSelection](); + } + } + ); + this[PropertySymbol.childNodesFlatten][PropertySymbol.addEventListener]( + 'remove', + (item: Node) => { + if (item instanceof Text) { + this[PropertySymbol.resetSelection](); + } + } + ); + } + /** * Returns validation message. * @@ -416,6 +442,17 @@ export default class HTMLTextAreaElement extends HTMLElement { * @returns Form. */ public get form(): HTMLFormElement { + const formID = this.getAttribute('form'); + + if (formID !== null) { + if (!this[PropertySymbol.isConnected]) { + return null; + } + return formID + ? (this[PropertySymbol.rootNode]).getElementById(formID) + : null; + } + return this[PropertySymbol.formNode]; } @@ -591,30 +628,4 @@ export default class HTMLTextAreaElement extends HTMLElement { this.#selectionDirection = HTMLInputElementSelectionDirectionEnum.none; } } - - /** - * @override - */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const oldFormNode = this[PropertySymbol.formNode]; - - super[PropertySymbol.connectToNode](parentNode); - - if (oldFormNode !== this[PropertySymbol.formNode]) { - if (oldFormNode) { - oldFormNode[PropertySymbol.removeFormControlItem](this, this.name); - oldFormNode[PropertySymbol.removeFormControlItem](this, this.id); - } - if (this[PropertySymbol.formNode]) { - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.name - ); - (this[PropertySymbol.formNode])[PropertySymbol.appendFormControlItem]( - this, - this.id - ); - } - } - } } diff --git a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts deleted file mode 100644 index dde3f294..00000000 --- a/packages/happy-dom/src/nodes/html-text-area-element/HTMLTextAreaElementNamedNodeMap.ts +++ /dev/null @@ -1,58 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; -import HTMLFormElement from '../html-form-element/HTMLFormElement.js'; -import HTMLTextAreaElement from './HTMLTextAreaElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class HTMLTextAreaElementNamedNodeMap extends HTMLElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: HTMLTextAreaElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - (item[PropertySymbol.name] === 'id' || item[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - if (replacedItem && replacedItem[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], replacedItem[PropertySymbol.value]); - } - if (item[PropertySymbol.value]) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.appendFormControlItem - ](this[PropertySymbol.ownerElement], item[PropertySymbol.value]); - } - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - (removedItem[PropertySymbol.name] === 'id' || removedItem[PropertySymbol.name] === 'name') && - this[PropertySymbol.ownerElement][PropertySymbol.formNode] - ) { - (this[PropertySymbol.ownerElement][PropertySymbol.formNode])[ - PropertySymbol.removeFormControlItem - ](this[PropertySymbol.ownerElement], removedItem[PropertySymbol.value]); - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/node/INodeList.ts b/packages/happy-dom/src/nodes/node/INodeList.ts new file mode 100644 index 00000000..6710a7d0 --- /dev/null +++ b/packages/happy-dom/src/nodes/node/INodeList.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable filenames/match-exported */ + +import * as PropertySymbol from '../../PropertySymbol.js'; +import TNodeListListener from './TNodeListListener.js'; + +/** + * NodeList. + * + * This interface is used to hide Array methods from the outside. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/NodeList + */ +interface NodeList { + readonly [index: number]: T; + + /** + * The number of items in the NodeList. + */ + readonly length: number; + + /** + * Returns `Symbol.toStringTag`. + * + * @returns `Symbol.toStringTag`. + */ + readonly [Symbol.toStringTag]: string; + + /** + * Returns `[object NodeList]`. + * + * @returns `[object NodeList]`. + */ + toLocaleString(): string; + + /** + * Returns `[object NodeList]`. + * + * @returns `[object NodeList]`. + */ + toString(): string; + + /** + * Returns item by index. + * + * @param index Index. + */ + item(index: number): T; + + /** + * Appends item. + * + * @param item Item. + * @returns True if added. + */ + [PropertySymbol.addItem](item: T): boolean; + + /** + * Inserts item before another item. + * + * @param newItem New item. + * @param [referenceItem] Reference item. + * @returns True if inserted. + */ + [PropertySymbol.insertItem](newItem: T, referenceItem: T | null): boolean; + + /** + * Removes item. + * + * @param item Item. + * @returns True if removed. + */ + [PropertySymbol.removeItem](item: T): boolean; + + /** + * Adds event listener. + * + * @param type Type. + * @param listener Listener. + */ + [PropertySymbol.addEventListener]( + type: 'add' | 'insert' | 'remove', + listener: TNodeListListener + ): void; + + /** + * Removes event listener. + * + * @param type Type. + * @param listener Listener. + */ + [PropertySymbol.removeEventListener]( + type: 'add' | 'insert' | 'remove', + listener: TNodeListListener + ): void; + + /** + * Dispatches event. + * + * @param type Type. + * @param item Item. + * @param referenceItem Reference item. + */ + [PropertySymbol.dispatchEvent]( + type: 'add' | 'insert' | 'remove', + item: T, + referenceItem?: T | null + ): void; + + /** + * Index of item. + * + * @param item Item. + * @returns Index. + */ + [PropertySymbol.indexOf](item: T): number; + + /** + * Returns true if the item is in the list. + * + * @param item Item. + * @returns True if the item is in the list. + */ + [PropertySymbol.includes](item: T): boolean; + + /** + * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object. + * + * @returns Iterator. + */ + [Symbol.iterator](): IterableIterator; + + /** + * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object. + * + * @returns Iterator. + */ + values(): IterableIterator; + + /** + * Returns an iterator, allowing you to go through all keys of the key/value pairs contained in this object. + * + * @returns Iterator. + * + */ + keys(): IterableIterator; + + /** + * Returns an iterator, allowing you to go through all key/value pairs contained in this object. + * + * @returns Iterator. + */ + entries(): IterableIterator<[number, T]>; +} + +export default NodeList; diff --git a/packages/happy-dom/src/nodes/node/Node.ts b/packages/happy-dom/src/nodes/node/Node.ts index a8be2c28..1ffb3d8d 100644 --- a/packages/happy-dom/src/nodes/node/Node.ts +++ b/packages/happy-dom/src/nodes/node/Node.ts @@ -9,6 +9,11 @@ import NodeUtility from './NodeUtility.js'; import Attr from '../attr/Attr.js'; import NodeList from './NodeList.js'; import NodeFactory from '../NodeFactory.js'; +import MutationRecord from '../../mutation-observer/MutationRecord.js'; +import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import INodeList from './INodeList.js'; /** * Node. @@ -58,12 +63,9 @@ export default class Node extends EventTarget { public [PropertySymbol.parentNode]: Node | null = null; public [PropertySymbol.nodeType]: NodeTypeEnum; public [PropertySymbol.rootNode]: Node = null; - public [PropertySymbol.formNode]: Node = null; - public [PropertySymbol.dataListNode]: Node = null; - public [PropertySymbol.selectNode]: Node = null; - public [PropertySymbol.textAreaNode]: Node = null; public [PropertySymbol.observers]: MutationListener[] = []; - public [PropertySymbol.childNodes]: NodeList = new NodeList(); + public [PropertySymbol.childNodes]: INodeList = new NodeList(); + public [PropertySymbol.childNodesFlatten]: INodeList = new NodeList(); /** * Constructor. @@ -135,7 +137,7 @@ export default class Node extends EventTarget { * * @returns Child nodes list. */ - public get childNodes(): NodeList { + public get childNodes(): INodeList { return this[PropertySymbol.childNodes]; } @@ -191,9 +193,9 @@ export default class Node extends EventTarget { */ public get previousSibling(): Node { if (this[PropertySymbol.parentNode]) { - const index = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( - this - ); + const index = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][ + PropertySymbol.indexOf + ](this); if (index > 0) { return (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][index - 1]; } @@ -208,9 +210,9 @@ export default class Node extends EventTarget { */ public get nextSibling(): Node { if (this[PropertySymbol.parentNode]) { - const index = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( - this - ); + const index = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][ + PropertySymbol.indexOf + ](this); if ( index > -1 && index + 1 < (this[PropertySymbol.parentNode])[PropertySymbol.childNodes].length @@ -339,6 +341,11 @@ export default class Node extends EventTarget { * @returns Appended node. */ public appendChild(node: Node): Node { + if (arguments.length < 1) { + throw new TypeError( + `Failed to execute 'appendChild' on 'Node': 1 argument required, but only 0 present` + ); + } return this[PropertySymbol.appendChild](node); } @@ -349,6 +356,11 @@ export default class Node extends EventTarget { * @returns Removed node. */ public removeChild(node: Node): Node { + if (arguments.length < 1) { + throw new TypeError( + `Failed to execute 'removeChild' on 'Node': 1 argument required, but only 0 present` + ); + } return this[PropertySymbol.removeChild](node); } @@ -376,6 +388,11 @@ export default class Node extends EventTarget { * @returns Replaced node. */ public replaceChild(newChild: Node, oldChild: Node): Node { + if (arguments.length < 2) { + throw new TypeError( + `Failed to execute 'replaceChild' on 'Node': 2 arguments required, but only ${arguments.length} present.` + ); + } return this[PropertySymbol.replaceChild](newChild, oldChild); } @@ -393,8 +410,9 @@ export default class Node extends EventTarget { // Document has childNodes directly when it is created if (clone[PropertySymbol.childNodes].length) { - for (const node of clone[PropertySymbol.childNodes].slice()) { - node[PropertySymbol.parentNode].removeChild(node); + const childNodes = clone[PropertySymbol.childNodes]; + while (childNodes.length) { + clone.removeChild(childNodes[0]); } } @@ -402,7 +420,7 @@ export default class Node extends EventTarget { for (const childNode of this[PropertySymbol.childNodes]) { const childClone = childNode.cloneNode(true); childClone[PropertySymbol.parentNode] = clone; - clone[PropertySymbol.childNodes].push(childClone); + clone[PropertySymbol.childNodes][PropertySymbol.appendChild](childClone); } } @@ -416,7 +434,82 @@ export default class Node extends EventTarget { * @returns Appended node. */ public [PropertySymbol.appendChild](node: Node): Node { - return NodeUtility.appendChild(this, node); + if (node === this) { + throw new DOMException( + "Failed to execute 'appendChild' on 'Node': Not possible to append a node as a child of itself." + ); + } + + if (NodeUtility.isInclusiveAncestor(node, this, true)) { + throw new DOMException( + "Failed to execute 'appendChild' on 'Node': The new node is a parent of the node to insert to.", + DOMExceptionNameEnum.domException + ); + } + + // If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node. + // See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment + if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + const childNodes = node[PropertySymbol.childNodes]; + while (childNodes.length) { + this.appendChild(childNodes[0]); + } + return node; + } + + // Remove the node from its previous parent if it has any. + if (node[PropertySymbol.parentNode]) { + node[PropertySymbol.parentNode][PropertySymbol.childNodes][PropertySymbol.removeChild](node); + let parent = node[PropertySymbol.parentNode]; + while (parent) { + node[PropertySymbol.parentNode][PropertySymbol.childNodesFlatten][ + PropertySymbol.removeChild + ](node); + parent = node[PropertySymbol.parentNode]; + } + } + + if (this[PropertySymbol.isConnected]) { + (this[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; + } + + this[PropertySymbol.childNodes][PropertySymbol.appendChild](node); + + let parent: Node = this; + while (parent) { + parent[PropertySymbol.childNodesFlatten][PropertySymbol.appendChild](node); + parent = parent[PropertySymbol.parentNode]; + } + + node[PropertySymbol.parentNode] = this; + + if (this[PropertySymbol.isConnected] && !(node)[PropertySymbol.isConnected]) { + (node)[PropertySymbol.isConnected] = true; + (node)[PropertySymbol.connectedToDocument](); + } else if (!this[PropertySymbol.isConnected] && (node)[PropertySymbol.isConnected]) { + (node)[PropertySymbol.isConnected] = false; + (node)[PropertySymbol.disconnectedFromDocument](); + } + + // MutationObserver + if ((this)[PropertySymbol.observers].length > 0) { + const record = new MutationRecord({ + target: this, + type: MutationTypeEnum.childList, + addedNodes: [node] + }); + + for (const observer of (this)[PropertySymbol.observers]) { + if (observer.options?.subtree) { + (node)[PropertySymbol.observe](observer); + } + if (observer.options?.childList) { + observer.report(record); + } + } + } + + return node; } /** @@ -426,7 +519,41 @@ export default class Node extends EventTarget { * @returns Removed node. */ public [PropertySymbol.removeChild](node: Node): Node { - return NodeUtility.removeChild(this, node); + if (this[PropertySymbol.isConnected]) { + (this[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; + } + + this[PropertySymbol.childNodes][PropertySymbol.removeChild](node); + + let parent: Node = this; + while (parent) { + parent[PropertySymbol.childNodesFlatten][PropertySymbol.removeChild](node); + parent = parent[PropertySymbol.parentNode]; + } + + if ((node)[PropertySymbol.isConnected]) { + (node)[PropertySymbol.disconnectedFromDocument](); + } + + // MutationObserver + if ((this)[PropertySymbol.observers].length > 0) { + const record = new MutationRecord({ + target: this, + type: MutationTypeEnum.childList, + removedNodes: [node] + }); + + for (const observer of (this)[PropertySymbol.observers]) { + if (observer.options?.subtree) { + (node)[PropertySymbol.unobserve](observer); + } + if (observer.options?.childList) { + observer.report(record); + } + } + } + + return node; } /** @@ -437,7 +564,88 @@ export default class Node extends EventTarget { * @returns Inserted node. */ public [PropertySymbol.insertBefore](newNode: Node, referenceNode: Node | null): Node { - return NodeUtility.insertBefore(this, newNode, referenceNode); + if (NodeUtility.isInclusiveAncestor(newNode, this, true)) { + throw new DOMException( + "Failed to execute 'insertBefore' on 'Node': The new node is a parent of the node to insert to.", + DOMExceptionNameEnum.domException + ); + } + + // If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node. + // See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment + if (newNode[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { + const childNodes = (newNode)[PropertySymbol.childNodes]; + while (childNodes.length > 0) { + this.insertBefore(childNodes[0], referenceNode); + } + return newNode; + } + + // If the referenceNode is null or undefined, then the newNode should be appended to the ancestorNode. + // According to spec only null is valid, but browsers support undefined as well. + if (!referenceNode) { + this.appendChild(newNode); + return newNode; + } + + if (!this[PropertySymbol.childNodes][PropertySymbol.includes](referenceNode)) { + throw new DOMException( + "Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node." + ); + } + + if (this[PropertySymbol.isConnected]) { + (this[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; + } + + if (newNode[PropertySymbol.parentNode]) { + newNode[PropertySymbol.parentNode][PropertySymbol.childNodes][PropertySymbol.removeChild]( + newNode + ); + let parent: Node = newNode[PropertySymbol.parentNode]; + while (parent) { + parent[PropertySymbol.childNodesFlatten][PropertySymbol.removeChild](newNode); + parent = parent[PropertySymbol.parentNode]; + } + } + + this[PropertySymbol.childNodes][PropertySymbol.insertBefore](newNode, referenceNode); + + let parent: Node = this; + while (parent) { + parent[PropertySymbol.childNodesFlatten][PropertySymbol.insertBefore](newNode, referenceNode); + parent = parent[PropertySymbol.parentNode]; + } + + newNode[PropertySymbol.parentNode] = this; + + if (this[PropertySymbol.isConnected] && !(newNode)[PropertySymbol.isConnected]) { + (newNode)[PropertySymbol.isConnected] = true; + (newNode)[PropertySymbol.connectedToDocument](); + } else if (!this[PropertySymbol.isConnected] && (newNode)[PropertySymbol.isConnected]) { + (newNode)[PropertySymbol.isConnected] = false; + (newNode)[PropertySymbol.disconnectedFromDocument](); + } + + // MutationObserver + if ((this)[PropertySymbol.observers].length > 0) { + const record = new MutationRecord({ + target: this, + type: MutationTypeEnum.childList, + addedNodes: [newNode] + }); + + for (const observer of (this)[PropertySymbol.observers]) { + if (observer.options?.subtree) { + (newNode)[PropertySymbol.observe](observer); + } + if (observer.options?.childList) { + observer.report(record); + } + } + } + + return newNode; } /** @@ -508,80 +716,51 @@ export default class Node extends EventTarget { } /** - * Connects this element to another element. - * - * @param parentNode Parent node. + * Called when connected to document. */ - public [PropertySymbol.connectToNode](parentNode: Node = null): void { - const isConnected = !!parentNode && parentNode[PropertySymbol.isConnected]; - const formNode = (this)[PropertySymbol.formNode]; - const dataListNode = (this)[PropertySymbol.dataListNode]; - const selectNode = (this)[PropertySymbol.selectNode]; - const textAreaNode = (this)[PropertySymbol.textAreaNode]; - + public [PropertySymbol.connectedToDocument](): void { if (this[PropertySymbol.nodeType] !== NodeTypeEnum.documentFragmentNode) { - this[PropertySymbol.parentNode] = parentNode; - this[PropertySymbol.rootNode] = - isConnected && parentNode ? (parentNode)[PropertySymbol.rootNode] : null; - - if (this['tagName'] !== 'FORM') { - (this)[PropertySymbol.formNode] = parentNode - ? (parentNode)[PropertySymbol.formNode] - : null; - } + this[PropertySymbol.rootNode] = this[PropertySymbol.parentNode][PropertySymbol.rootNode]; + } - if (this['tagName'] !== 'DATALIST') { - (this)[PropertySymbol.dataListNode] = parentNode - ? (parentNode)[PropertySymbol.dataListNode] - : null; - } + if (this.connectedCallback) { + this.connectedCallback(); + } - if (this['tagName'] !== 'SELECT') { - (this)[PropertySymbol.selectNode] = parentNode - ? (parentNode)[PropertySymbol.selectNode] - : null; - } + for (const child of this[PropertySymbol.childNodes]) { + (child)[PropertySymbol.connectedToDocument](); + } - if (this['tagName'] !== 'TEXTAREA') { - (this)[PropertySymbol.textAreaNode] = parentNode - ? (parentNode)[PropertySymbol.textAreaNode] - : null; - } + // eslint-disable-next-line + if ((this)[PropertySymbol.shadowRoot]) { + // eslint-disable-next-line + (this)[PropertySymbol.shadowRoot][PropertySymbol.connectedToDocument](); } + } - if (this[PropertySymbol.isConnected] !== isConnected) { - this[PropertySymbol.isConnected] = isConnected; + /** + * Called when disconnected from document. + * @param e + */ + public [PropertySymbol.disconnectedFromDocument](): void { + this[PropertySymbol.rootNode] = null; - if (!isConnected) { - if (this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] === this) { - this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = null; - } - } + if (this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] === this) { + this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = null; + } - if (isConnected && this.connectedCallback) { - this.connectedCallback(); - } else if (!isConnected && this.disconnectedCallback) { - this.disconnectedCallback(); - } + if (this.disconnectedCallback) { + this.disconnectedCallback(); + } - for (const child of this[PropertySymbol.childNodes]) { - (child)[PropertySymbol.connectToNode](this); - } + for (const child of this[PropertySymbol.childNodes]) { + (child)[PropertySymbol.disconnectedFromDocument](); + } + // eslint-disable-next-line + if ((this)[PropertySymbol.shadowRoot]) { // eslint-disable-next-line - if ((this)[PropertySymbol.shadowRoot]) { - // eslint-disable-next-line - (this)[PropertySymbol.shadowRoot][PropertySymbol.connectToNode](this); - } - } else if ( - formNode !== this[PropertySymbol.formNode] || - dataListNode !== this[PropertySymbol.dataListNode] || - selectNode !== this[PropertySymbol.selectNode] || - textAreaNode !== this[PropertySymbol.textAreaNode] - ) { - for (const child of this[PropertySymbol.childNodes]) { - (child)[PropertySymbol.connectToNode](this); - } + (this)[PropertySymbol.shadowRoot][PropertySymbol.disconnectedFromDocument](); } } @@ -729,9 +908,9 @@ export default class Node extends EventTarget { const node2Node = reverseArrayIndex(node2Ancestors, commonAncestorIndex + 1); const node1Node = reverseArrayIndex(node1Ancestors, commonAncestorIndex + 1); - const computeNodeIndexes = (nodes: Node[]): void => { + const computeNodeIndexes = (nodes: INodeList): void => { for (const childNode of nodes) { - computeNodeIndexes((childNode)[PropertySymbol.childNodes]); + computeNodeIndexes(childNode[PropertySymbol.childNodes]); if (childNode === node2Node) { node2Index = indexes; @@ -747,7 +926,7 @@ export default class Node extends EventTarget { } }; - computeNodeIndexes((commonAncestor)[PropertySymbol.childNodes]); + computeNodeIndexes(commonAncestor[PropertySymbol.childNodes]); /** * 9. If node1 is preceding node2, then return DOCUMENT_POSITION_PRECEDING. diff --git a/packages/happy-dom/src/nodes/node/NodeList.ts b/packages/happy-dom/src/nodes/node/NodeList.ts index e5b6095f..9685fc26 100644 --- a/packages/happy-dom/src/nodes/node/NodeList.ts +++ b/packages/happy-dom/src/nodes/node/NodeList.ts @@ -1,14 +1,53 @@ +import * as PropertySymbol from '../../PropertySymbol.js'; +import INodeList from './INodeList.js'; +import DOMException from '../../exception/DOMException.js'; +import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; +import TNodeListListener from './TNodeListListener.js'; + /** - * Class list. + * NodeList. + * + * We are extending Array here to improve performance. + * However, we should not expose Array methods to the outside. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/NodeList */ -export default class NodeList extends Array { +class NodeList extends Array implements INodeList { + #eventListeners: { + add: WeakRef>[]; + insert: WeakRef>[]; + remove: WeakRef>[]; + } = { + add: [], + insert: [], + remove: [] + }; + /** * Returns `Symbol.toStringTag`. * * @returns `Symbol.toStringTag`. */ public get [Symbol.toStringTag](): string { - return this.constructor.name; + return 'NodeList'; + } + + /** + * Returns `[object NodeList]`. + * + * @returns `[object NodeList]`. + */ + public toLocaleString(): string { + return '[object NodeList]'; + } + + /** + * Returns `[object NodeList]`. + * + * @returns `[object NodeList]`. + */ + public toString(): string { + return '[object NodeList]'; } /** @@ -19,4 +58,173 @@ export default class NodeList extends Array { public item(index: number): T { return index >= 0 && this[index] ? this[index] : null; } + + /** + * Appends item. + * + * @param item Item. + * @returns True if added. + */ + public [PropertySymbol.addItem](item: T): boolean { + if (super.includes(item)) { + this[PropertySymbol.removeItem](item); + } + + super.push(item); + + this[PropertySymbol.dispatchEvent]('add', item); + + return true; + } + + /** + * Inserts item before another item. + * + * @param newItem New item. + * @param [referenceItem] Reference item. + * @returns True if inserted. + */ + public [PropertySymbol.insertItem](newItem: T, referenceItem: T | null): boolean { + if (!referenceItem) { + return this[PropertySymbol.appendChild](newItem); + } + + if (super.includes(newItem)) { + this[PropertySymbol.removeItem](newItem); + } + + const index = super.indexOf(referenceItem); + + if (index === -1) { + throw new DOMException( + "Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.", + DOMExceptionNameEnum.notFoundError + ); + } + + super.splice(index, 0, newItem); + + this[PropertySymbol.dispatchEvent]('insert', newItem, referenceItem); + + return true; + } + + /** + * Removes item. + * + * @param item Item. + * @returns True if removed. + */ + public [PropertySymbol.removeItem](item: T): boolean { + const index = super.indexOf(item); + + if (index === -1) { + throw new DOMException( + "Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.", + DOMExceptionNameEnum.notFoundError + ); + } + + super.splice(index, 1); + + this[PropertySymbol.dispatchEvent]('remove', item); + + return true; + } + + /** + * Adds event listener. + * + * @param type Type. + * @param listener Listener. + */ + public [PropertySymbol.addEventListener]( + type: 'add' | 'insert' | 'remove', + listener: TNodeListListener + ): void { + this.#eventListeners[type].push(new WeakRef(listener)); + } + + /** + * Removes event listener. + * + * @param type Type. + * @param listener Listener. + */ + public [PropertySymbol.removeEventListener]( + type: 'add' | 'insert' | 'remove', + listener: TNodeListListener + ): void { + const listeners = this.#eventListeners[type]; + for (let i = 0, max = listeners.length; i < max; i++) { + if (listeners[i].deref() === listener) { + listeners.splice(i, 1); + return; + } + } + } + + /** + * Dispatches event. + * + * @param type Type. + * @param item Item. + * @param referenceItem Reference item. + */ + public [PropertySymbol.dispatchEvent]( + type: 'add' | 'insert' | 'remove', + item: T, + referenceItem?: T | null + ): void { + const listeners = this.#eventListeners[type]; + for (let i = 0, max = listeners.length; i < max; i++) { + const listener = listeners[i].deref(); + if (listener) { + listener(item, referenceItem); + } else { + listeners.splice(i, 1); + i--; + max--; + } + } + } + + /** + * Index of item. + * + * @param item Item. + * @returns Index. + */ + public [PropertySymbol.indexOf](item: T): number { + return super.indexOf(item); + } + + /** + * Returns true if the item is in the list. + * + * @param item Item. + * @returns True if the item is in the list. + */ + public [PropertySymbol.includes](item: T): boolean { + return super.includes(item); + } } + +// Removes Array methods from NodeList. +const descriptors = Object.getOwnPropertyDescriptors(Array.prototype); +for (const key of Object.keys(descriptors)) { + const descriptor = descriptors[key]; + if (key === 'length') { + Object.defineProperty(NodeList.prototype, key, { + set: () => {}, + get: descriptor.get + }); + } else if (key !== 'values' && key !== 'keys' && key !== 'entries') { + if (typeof descriptor.value === 'function') { + Object.defineProperty(NodeList.prototype, key, {}); + } + } +} + +// Forces the type to be an interface to hide Array methods from the outside. +export default () => INodeList>(NodeList); diff --git a/packages/happy-dom/src/nodes/node/NodeUtility.ts b/packages/happy-dom/src/nodes/node/NodeUtility.ts index 1382f689..2bb74924 100644 --- a/packages/happy-dom/src/nodes/node/NodeUtility.ts +++ b/packages/happy-dom/src/nodes/node/NodeUtility.ts @@ -8,223 +8,11 @@ import DocumentType from '../document-type/DocumentType.js'; import Attr from '../attr/Attr.js'; import ProcessingInstruction from '../processing-instruction/ProcessingInstruction.js'; import ShadowRoot from '../shadow-root/ShadowRoot.js'; -import DOMException from '../../exception/DOMException.js'; -import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; -import MutationRecord from '../../mutation-observer/MutationRecord.js'; -import MutationTypeEnum from '../../mutation-observer/MutationTypeEnum.js'; /** * Node utility. */ export default class NodeUtility { - /** - * Append a child node to childNodes. - * - * @param ancestorNode Ancestor node. - * @param node Node to append. - * @param [options] Options. - * @param [options.disableAncestorValidation] Disables validation for checking if the node is an ancestor of the ancestorNode. - * @returns Appended node. - */ - public static appendChild( - ancestorNode: Node, - node: Node, - options?: { disableAncestorValidation?: boolean } - ): Node { - if (node === ancestorNode) { - throw new DOMException( - "Failed to execute 'appendChild' on 'Node': Not possible to append a node as a child of itself." - ); - } - - if (!options?.disableAncestorValidation && this.isInclusiveAncestor(node, ancestorNode, true)) { - throw new DOMException( - "Failed to execute 'appendChild' on 'Node': The new node is a parent of the node to insert to.", - DOMExceptionNameEnum.domException - ); - } - - // If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node. - // See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment - if (node[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { - for (const child of (node)[PropertySymbol.childNodes].slice()) { - ancestorNode.appendChild(child); - } - return node; - } - - // Remove the node from its previous parent if it has any. - if (node[PropertySymbol.parentNode]) { - const index = (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( - node - ); - if (index !== -1) { - (node[PropertySymbol.parentNode])[PropertySymbol.childNodes].splice(index, 1); - } - } - - if (ancestorNode[PropertySymbol.isConnected]) { - (ancestorNode[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; - } - - (ancestorNode)[PropertySymbol.childNodes].push(node); - - (node)[PropertySymbol.connectToNode](ancestorNode); - - // MutationObserver - if ((ancestorNode)[PropertySymbol.observers].length > 0) { - const record = new MutationRecord({ - target: ancestorNode, - type: MutationTypeEnum.childList, - addedNodes: [node] - }); - - for (const observer of (ancestorNode)[PropertySymbol.observers]) { - if (observer.options?.subtree) { - (node)[PropertySymbol.observe](observer); - } - if (observer.options?.childList) { - observer.report(record); - } - } - } - - return node; - } - - /** - * Remove Child element from childNodes array. - * - * @param ancestorNode Ancestor node. - * @param node Node to remove. - * @returns Removed node. - */ - public static removeChild(ancestorNode: Node, node: Node): Node { - const index = (ancestorNode)[PropertySymbol.childNodes].indexOf(node); - - if (index === -1) { - throw new DOMException('Failed to remove node. Node is not child of parent.'); - } - - if (ancestorNode[PropertySymbol.isConnected]) { - (ancestorNode[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; - } - - (ancestorNode)[PropertySymbol.childNodes].splice(index, 1); - - (node)[PropertySymbol.connectToNode](null); - - // MutationObserver - if ((ancestorNode)[PropertySymbol.observers].length > 0) { - const record = new MutationRecord({ - target: ancestorNode, - type: MutationTypeEnum.childList, - removedNodes: [node] - }); - - for (const observer of (ancestorNode)[PropertySymbol.observers]) { - if (observer.options?.subtree) { - (node)[PropertySymbol.unobserve](observer); - } - if (observer.options?.childList) { - observer.report(record); - } - } - } - - return node; - } - - /** - * Inserts a node before another. - * - * @param ancestorNode Ancestor node. - * @param newNode Node to insert. - * @param referenceNode Node to insert before. - * @param [options] Options. - * @param [options.disableAncestorValidation] Disables validation for checking if the node is an ancestor of the ancestorNode. - * @returns Inserted node. - */ - public static insertBefore( - ancestorNode: Node, - newNode: Node, - referenceNode: Node | null, - options?: { disableAncestorValidation?: boolean } - ): Node { - if ( - !options?.disableAncestorValidation && - this.isInclusiveAncestor(newNode, ancestorNode, true) - ) { - throw new DOMException( - "Failed to execute 'insertBefore' on 'Node': The new node is a parent of the node to insert to.", - DOMExceptionNameEnum.domException - ); - } - - // If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node. - // See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment - if (newNode[PropertySymbol.nodeType] === NodeTypeEnum.documentFragmentNode) { - for (const child of (newNode)[PropertySymbol.childNodes].slice()) { - ancestorNode.insertBefore(child, referenceNode); - } - return newNode; - } - - // If the referenceNode is null or undefined, then the newNode should be appended to the ancestorNode. - // According to spec only null is valid, but browsers support undefined as well. - if (!referenceNode) { - ancestorNode.appendChild(newNode); - return newNode; - } - - if ((ancestorNode)[PropertySymbol.childNodes].indexOf(referenceNode) === -1) { - throw new DOMException( - "Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node." - ); - } - - if (ancestorNode[PropertySymbol.isConnected]) { - (ancestorNode[PropertySymbol.ownerDocument] || this)[PropertySymbol.cacheID]++; - } - - if (newNode[PropertySymbol.parentNode]) { - const index = (newNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].indexOf( - newNode - ); - if (index !== -1) { - (newNode[PropertySymbol.parentNode])[PropertySymbol.childNodes].splice(index, 1); - } - } - - (ancestorNode)[PropertySymbol.childNodes].splice( - (ancestorNode)[PropertySymbol.childNodes].indexOf(referenceNode), - 0, - newNode - ); - - (newNode)[PropertySymbol.connectToNode](ancestorNode); - - // MutationObserver - if ((ancestorNode)[PropertySymbol.observers].length > 0) { - const record = new MutationRecord({ - target: ancestorNode, - type: MutationTypeEnum.childList, - addedNodes: [newNode] - }); - - for (const observer of (ancestorNode)[PropertySymbol.observers]) { - if (observer.options?.subtree) { - (newNode)[PropertySymbol.observe](observer); - } - if (observer.options?.childList) { - observer.report(record); - } - } - } - - return newNode; - } - /** * Returns whether the passed node is a text node, and narrows its type. * diff --git a/packages/happy-dom/src/nodes/node/TNodeListListener.ts b/packages/happy-dom/src/nodes/node/TNodeListListener.ts new file mode 100644 index 00000000..a7d9f615 --- /dev/null +++ b/packages/happy-dom/src/nodes/node/TNodeListListener.ts @@ -0,0 +1,2 @@ +type TNodeListListener = (item: T, referenceItem?: T | null) => void; +export default TNodeListListener; diff --git a/packages/happy-dom/src/nodes/parent-node/IParentNode.ts b/packages/happy-dom/src/nodes/parent-node/IParentNode.ts index 1aa2af34..7fdc108b 100644 --- a/packages/happy-dom/src/nodes/parent-node/IParentNode.ts +++ b/packages/happy-dom/src/nodes/parent-node/IParentNode.ts @@ -1,4 +1,4 @@ -import HTMLCollection from '../element/HTMLCollection.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; import Element from '../element/Element.js'; import Node from '../node/Node.js'; import NodeList from '../node/NodeList.js'; diff --git a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts index 44b837da..001423f1 100644 --- a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts +++ b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts @@ -3,7 +3,7 @@ import * as PropertySymbol from '../../PropertySymbol.js'; import DocumentFragment from '../document-fragment/DocumentFragment.js'; import Document from '../document/Document.js'; import Element from '../element/Element.js'; -import HTMLCollection from '../element/HTMLCollection.js'; +import HTMLCollection from '../element/HTMLCollection2.js'; import Node from '../node/Node.js'; import NamespaceURI from '../../config/NamespaceURI.js'; @@ -67,8 +67,12 @@ export default class ParentNodeUtility { parentNode: Element | Document | DocumentFragment, ...nodes: (string | Node)[] ): void { - for (const node of (parentNode)[PropertySymbol.childNodes].slice()) { - parentNode.removeChild(node); + const childNodes = (parentNode)[PropertySymbol.childNodes][ + PropertySymbol.items + ]; + + while (childNodes.length) { + parentNode.removeChild(childNodes[0]); } this.append(parentNode, ...nodes); @@ -85,15 +89,20 @@ export default class ParentNodeUtility { parentNode: Element | DocumentFragment | Document, className: string ): HTMLCollection { - let matches = new HTMLCollection(); + const matches = new HTMLCollection(); - for (const child of (parentNode)[PropertySymbol.children]) { + for (const child of (parentNode)[PropertySymbol.children][ + PropertySymbol.items + ]) { if (child.className.split(' ').includes(className)) { - matches.push(child); + matches[PropertySymbol.addItem](child); + } + + for (const subChild of this.getElementsByClassName(child, className)[ + PropertySymbol.items + ]) { + matches[PropertySymbol.addItem](subChild); } - matches = >( - matches.concat(this.getElementsByClassName(child, className)) - ); } return matches; @@ -114,7 +123,9 @@ export default class ParentNodeUtility { const includeAll = tagName === '*'; let matches = new HTMLCollection(); - for (const child of (parentNode)[PropertySymbol.children]) { + for (const child of (parentNode)[PropertySymbol.children][ + PropertySymbol.items + ]) { if (includeAll || child[PropertySymbol.tagName].toUpperCase() === upperTagName) { matches.push(child); } @@ -196,7 +207,7 @@ export default class ParentNodeUtility { public static getElementById( parentNode: Element | DocumentFragment | Document, id: string - ): Element { + ): Element | null { id = String(id); for (const child of (parentNode)[PropertySymbol.children]) { if (child.id === id) { diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts index 80146d9a..ee3f9aaf 100644 --- a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts +++ b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts @@ -4,10 +4,9 @@ import Element from '../element/Element.js'; import SVGSVGElement from './SVGSVGElement.js'; import Event from '../../event/Event.js'; import HTMLElementUtility from '../html-element/HTMLElementUtility.js'; -import NamedNodeMap from '../../named-node-map/NamedNodeMap.js'; -import SVGElementNamedNodeMap from './SVGElementNamedNodeMap.js'; import DatasetFactory from '../element/DatasetFactory.js'; import IDataset from '../element/IDataset.js'; +import Attr from '../attr/Attr.js'; /** * SVG Element. @@ -25,12 +24,26 @@ export default class SVGElement extends Element { public onunload: (event: Event) => void | null = null; // Internal properties - public override [PropertySymbol.attributes]: NamedNodeMap = new SVGElementNamedNodeMap(this); public [PropertySymbol.style]: CSSStyleDeclaration | null = null; // Private properties #dataset: IDataset = null; + /** + * Constructor. + */ + constructor() { + super(); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'set', + this.#onSetAttribute.bind(this) + ); + this[PropertySymbol.attributes][PropertySymbol.addEventListener]( + 'remove', + this.#onRemoveAttribute.bind(this) + ); + } + /** * Returns viewport. * @@ -114,4 +127,26 @@ export default class SVGElement extends Element { public focus(): void { HTMLElementUtility.focus(this); } + + /** + * Triggered when an attribute is set. + * + * @param item Item + */ + #onSetAttribute(item: Attr): void { + if (item[PropertySymbol.name] === 'style' && this[PropertySymbol.style]) { + this[PropertySymbol.style].cssText = item[PropertySymbol.value]; + } + } + + /** + * Triggered when an attribute is removed. + * + * @param removedItem Removed item. + */ + #onRemoveAttribute(removedItem: Attr): void { + if (removedItem && removedItem[PropertySymbol.name] === 'style' && this[PropertySymbol.style]) { + this[PropertySymbol.style].cssText = ''; + } + } } diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts b/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts deleted file mode 100644 index d8d09688..00000000 --- a/packages/happy-dom/src/nodes/svg-element/SVGElementNamedNodeMap.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Attr from '../attr/Attr.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import ElementNamedNodeMap from '../element/ElementNamedNodeMap.js'; -import SVGElement from './SVGElement.js'; - -/** - * Named Node Map. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap - */ -export default class SVGElementNamedNodeMap extends ElementNamedNodeMap { - protected [PropertySymbol.ownerElement]: SVGElement; - - /** - * @override - */ - public override [PropertySymbol.setNamedItem](item: Attr): Attr | null { - const replacedItem = super[PropertySymbol.setNamedItem](item); - - if ( - item[PropertySymbol.name] === 'style' && - this[PropertySymbol.ownerElement][PropertySymbol.style] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = item[PropertySymbol.value]; - } - - return replacedItem || null; - } - - /** - * @override - */ - public override [PropertySymbol.removeNamedItem](name: string): Attr | null { - const removedItem = super[PropertySymbol.removeNamedItem](name); - - if ( - removedItem && - removedItem[PropertySymbol.name] === 'style' && - this[PropertySymbol.ownerElement][PropertySymbol.style] - ) { - this[PropertySymbol.ownerElement][PropertySymbol.style].cssText = ''; - } - - return removedItem; - } -} diff --git a/packages/happy-dom/src/nodes/text/Text.ts b/packages/happy-dom/src/nodes/text/Text.ts index eb29afc3..2baebb60 100644 --- a/packages/happy-dom/src/nodes/text/Text.ts +++ b/packages/happy-dom/src/nodes/text/Text.ts @@ -85,22 +85,4 @@ export default class Text extends CharacterData { public override [PropertySymbol.cloneNode](deep = false): Text { return super[PropertySymbol.cloneNode](deep); } - - /** - * @override - */ - public override [PropertySymbol.connectToNode](parentNode: Node = null): void { - const oldTextAreaNode = this[PropertySymbol.textAreaNode]; - - super[PropertySymbol.connectToNode](parentNode); - - if (oldTextAreaNode !== this[PropertySymbol.textAreaNode]) { - if (oldTextAreaNode) { - oldTextAreaNode[PropertySymbol.resetSelection](); - } - if (this[PropertySymbol.textAreaNode]) { - (this[PropertySymbol.textAreaNode])[PropertySymbol.resetSelection](); - } - } - } } diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index b4e42f88..233006f9 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -333,6 +333,7 @@ export default class QuerySelector { for (let i = 0, max = children.length; i < max; i++) { const child = children[i]; + const childrenOfChild = (child)[PropertySymbol.children][PropertySymbol.items]; const position = (documentPosition ? documentPosition + '>' : '') + String.fromCharCode(i); if (selectorItem.match(child)) { @@ -360,29 +361,16 @@ export default class QuerySelector { case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: matched = matched.concat( - this.findAll( - rootElement, - (child)[PropertySymbol.children], - selectorItems.slice(1), - position - ) + this.findAll(rootElement, childrenOfChild, selectorItems.slice(1), position) ); break; } } } - if ( - selectorItem.combinator === SelectorCombinatorEnum.descendant && - (child)[PropertySymbol.children].length - ) { + if (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) { matched = matched.concat( - this.findAll( - rootElement, - (child)[PropertySymbol.children], - selectorItems, - position - ) + this.findAll(rootElement, childrenOfChild, selectorItems, position) ); } } @@ -407,6 +395,8 @@ export default class QuerySelector { const nextSelectorItem = selectorItems[1]; for (const child of children) { + const childrenOfChild = (child)[PropertySymbol.children][PropertySymbol.items]; + if (selectorItem.match(child)) { if (!nextSelectorItem) { if (rootElement !== child) { @@ -428,11 +418,7 @@ export default class QuerySelector { break; case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: - const match = this.findFirst( - rootElement, - (child)[PropertySymbol.children], - selectorItems.slice(1) - ); + const match = this.findFirst(rootElement, childrenOfChild, selectorItems.slice(1)); if (match) { return match; } @@ -441,15 +427,8 @@ export default class QuerySelector { } } - if ( - selectorItem.combinator === SelectorCombinatorEnum.descendant && - (child)[PropertySymbol.children].length - ) { - const match = this.findFirst( - rootElement, - (child)[PropertySymbol.children], - selectorItems - ); + if (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) { + const match = this.findFirst(rootElement, childrenOfChild, selectorItems); if (match) { return match; diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 82aa134a..fffd1091 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -236,7 +236,9 @@ export default class SelectorItem { ? { priorityWeight: 10 } : null; case 'empty': - return !(element)[PropertySymbol.children].length ? { priorityWeight: 10 } : null; + return !(element)[PropertySymbol.children][PropertySymbol.items].length + ? { priorityWeight: 10 } + : null; case 'root': return element[PropertySymbol.tagName] === 'HTML' ? { priorityWeight: 10 } : null; case 'not': diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts index 0b386ec8..22a7683f 100644 --- a/packages/happy-dom/src/window/BrowserWindow.ts +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -70,7 +70,7 @@ import Location from '../location/Location.js'; import MediaQueryList from '../match-media/MediaQueryList.js'; import MutationObserver from '../mutation-observer/MutationObserver.js'; import MutationRecord from '../mutation-observer/MutationRecord.js'; -import NamedNodeMap from '../named-node-map/NamedNodeMap.js'; +import NamedNodeMap from '../nodes/element/NamedNodeMap.js'; import MimeType from '../navigator/MimeType.js'; import MimeTypeArray from '../navigator/MimeTypeArray.js'; import Navigator from '../navigator/Navigator.js'; @@ -86,7 +86,7 @@ import DocumentReadyStateEnum from '../nodes/document/DocumentReadyStateEnum.js' import DocumentReadyStateManager from '../nodes/document/DocumentReadyStateManager.js'; import DOMRect from '../nodes/element/DOMRect.js'; import Element from '../nodes/element/Element.js'; -import HTMLCollection from '../nodes/element/HTMLCollection.js'; +import HTMLCollection from '../nodes/element/HTMLCollection2.js'; import HTMLAnchorElement from '../nodes/html-anchor-element/HTMLAnchorElement.js'; import HTMLAreaElement from '../nodes/html-area-element/HTMLAreaElement.js'; import AudioImplementation from '../nodes/html-audio-element/Audio.js'; diff --git a/packages/happy-dom/test/css/declaration/element-style/CSSStyleDeclarationElementStyle.test.ts b/packages/happy-dom/test/css/declaration/element-style/CSSStyleDeclarationElementStyle.test.ts index 98777d18..a6a588fe 100644 --- a/packages/happy-dom/test/css/declaration/element-style/CSSStyleDeclarationElementStyle.test.ts +++ b/packages/happy-dom/test/css/declaration/element-style/CSSStyleDeclarationElementStyle.test.ts @@ -1,5 +1,4 @@ import Window from '../../../../src/window/Window.js'; -import Window from '../../../../src/window/Window.js'; import Document from '../../../../src/nodes/document/Document.js'; import HTMLElement from '../../../../src/nodes/html-element/HTMLElement.js'; import CSSStyleDeclarationElementStyle from '../../../../src/css/declaration/element-style/CSSStyleDeclarationElementStyle.js'; diff --git a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts b/packages/happy-dom/test/nodes/element/NamedNodeMap.test.ts similarity index 91% rename from packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts rename to packages/happy-dom/test/nodes/element/NamedNodeMap.test.ts index ac14a649..e9164de7 100644 --- a/packages/happy-dom/test/named-node-map/NamedNodeMap.test.ts +++ b/packages/happy-dom/test/nodes/element/NamedNodeMap.test.ts @@ -1,10 +1,10 @@ -import Window from '../../src/window/Window.js'; -import Document from '../../src/nodes/document/Document.js'; -import Element from '../../src/nodes/element/Element.js'; -import NamedNodeMap from '../../src/named-node-map/NamedNodeMap.js'; -import Attr from '../../src/nodes/attr/Attr.js'; -import DOMException from '../../src/exception/DOMException.js'; -import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum.js'; +import Window from '../../../src/window/Window.js'; +import Document from '../../../src/nodes/document/Document.js'; +import Element from '../../../src/nodes/element/Element.js'; +import NamedNodeMap from '../../../src/nodes/element/NamedNodeMap.js'; +import Attr from '../../../src/nodes/attr/Attr.js'; +import DOMException from '../../../src/exception/DOMException.js'; +import DOMExceptionNameEnum from '../../../src/exception/DOMExceptionNameEnum.js'; import { beforeEach, describe, it, expect } from 'vitest'; describe('NamedNodeMap', () => { diff --git a/packages/happy-dom/test/nodes/html-button-element/HTMLButtonElement.test.ts b/packages/happy-dom/test/nodes/html-button-element/HTMLButtonElement.test.ts index 8c1fe28c..0dab5144 100644 --- a/packages/happy-dom/test/nodes/html-button-element/HTMLButtonElement.test.ts +++ b/packages/happy-dom/test/nodes/html-button-element/HTMLButtonElement.test.ts @@ -237,7 +237,7 @@ describe('HTMLButtonElement', () => { expect(element.form).toBe(form); }); - it('Returns form element by id if the form attribute is set.', () => { + it('Returns form element by id if the form attribute is set when connecting node to DOM.', () => { const form = document.createElement('form'); form.id = 'form'; document.body.appendChild(form); @@ -245,6 +245,17 @@ describe('HTMLButtonElement', () => { expect(element.form).toBe(null); document.body.appendChild(element); expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); + }); + + it('Returns form element by id if the form attribute is set when element is connected to DOM.', () => { + const form = document.createElement('form'); + form.id = 'form'; + document.body.appendChild(form); + document.body.appendChild(element); + element.setAttribute('form', 'form'); + expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); }); }); diff --git a/packages/happy-dom/test/nodes/html-field-set-element/HTMLFieldSetElement.test.ts b/packages/happy-dom/test/nodes/html-field-set-element/HTMLFieldSetElement.test.ts index 9ae355ec..eef54a02 100644 --- a/packages/happy-dom/test/nodes/html-field-set-element/HTMLFieldSetElement.test.ts +++ b/packages/happy-dom/test/nodes/html-field-set-element/HTMLFieldSetElement.test.ts @@ -2,6 +2,8 @@ import HTMLFieldSetElement from '../../../src/nodes/html-field-set-element/HTMLF import Window from '../../../src/window/Window.js'; import Document from '../../../src/nodes/document/Document.js'; import { beforeEach, describe, it, expect } from 'vitest'; +import HTMLFormElement from '../../../src/nodes/html-form-element/HTMLFormElement.js'; +import HTMLElement from '../../../src/nodes/html-element/HTMLElement.js'; describe('HTMLFieldSetElement', () => { let window: Window; @@ -19,4 +21,67 @@ describe('HTMLFieldSetElement', () => { expect(element instanceof HTMLFieldSetElement).toBe(true); }); }); + + describe('get form()', () => { + it('Returns null if no parent form element exists.', () => { + expect(element.form).toBe(null); + }); + + it('Returns parent form element.', () => { + const form = document.createElement('form'); + const div = document.createElement('div'); + div.appendChild(element); + form.appendChild(div); + expect(element.form).toBe(form); + }); + + it('Returns form element by id if the form attribute is set when connecting node to DOM.', () => { + const form = document.createElement('form'); + form.id = 'form'; + document.body.appendChild(form); + element.setAttribute('form', 'form'); + expect(element.form).toBe(null); + document.body.appendChild(element); + expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); + }); + + it('Returns form element by id if the form attribute is set when element is connected to DOM.', () => { + const form = document.createElement('form'); + form.id = 'form'; + document.body.appendChild(form); + document.body.appendChild(element); + element.setAttribute('form', 'form'); + expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); + }); + }); + + describe('get name()', () => { + it(`Returns the attribute "name".`, () => { + element.setAttribute('name', 'VALUE'); + expect(element.name).toBe('VALUE'); + }); + }); + + describe('set name()', () => { + it(`Sets the attribute "name".`, () => { + element.name = 'VALUE'; + expect(element.getAttribute('name')).toBe('VALUE'); + }); + + it(`Sets name as property in parent form elements.`, () => { + const form = document.createElement('form'); + form.appendChild(element); + element.name = 'button1'; + expect(form.elements['button1']).toBe(element); + }); + + it(`Sets name as property in parent element children.`, () => { + const div = document.createElement('div'); + div.appendChild(element); + element.name = 'button1'; + expect(div.children['button1']).toBe(element); + }); + }); }); diff --git a/packages/happy-dom/test/nodes/html-input-element/HTMLInputElement.test.ts b/packages/happy-dom/test/nodes/html-input-element/HTMLInputElement.test.ts index 6a6e4560..fa120c7e 100644 --- a/packages/happy-dom/test/nodes/html-input-element/HTMLInputElement.test.ts +++ b/packages/happy-dom/test/nodes/html-input-element/HTMLInputElement.test.ts @@ -619,7 +619,7 @@ describe('HTMLInputElement', () => { expect(element.form).toBe(form); }); - it('Returns form element by id if the form attribute is set.', () => { + it('Returns form element by id if the form attribute is set when connecting node to DOM.', () => { const form = document.createElement('form'); form.id = 'form'; document.body.appendChild(form); @@ -627,6 +627,17 @@ describe('HTMLInputElement', () => { expect(element.form).toBe(null); document.body.appendChild(element); expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); + }); + + it('Returns form element by id if the form attribute is set when element is connected to DOM.', () => { + const form = document.createElement('form'); + form.id = 'form'; + document.body.appendChild(form); + document.body.appendChild(element); + element.setAttribute('form', 'form'); + expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); }); }); diff --git a/packages/happy-dom/test/nodes/html-label-element/HTMLLabelElement.test.ts b/packages/happy-dom/test/nodes/html-label-element/HTMLLabelElement.test.ts index 88a61b1e..4fcb96ef 100644 --- a/packages/happy-dom/test/nodes/html-label-element/HTMLLabelElement.test.ts +++ b/packages/happy-dom/test/nodes/html-label-element/HTMLLabelElement.test.ts @@ -70,12 +70,28 @@ describe('HTMLLabelElement', () => { }); describe('get form()', () => { + it('Returns null if no parent form element exists.', () => { + expect(element.form).toBe(null); + }); + it('Returns parent form element.', () => { const form = document.createElement('form'); const div = document.createElement('div'); div.appendChild(element); form.appendChild(div); - expect(element.form === form).toBe(true); + expect(element.form).toBe(form); + }); + + it('Returns associated control form element.', () => { + const form = document.createElement('form'); + const input = document.createElement('input'); + form.id = 'form'; + document.body.appendChild(form); + input.id = 'input'; + input.setAttribute('form', 'form'); + element.htmlFor = 'input'; + document.body.appendChild(element); + expect(element.form).toBe(form); }); }); diff --git a/packages/happy-dom/test/nodes/html-text-area-element/HTMLTextAreaElement.test.ts b/packages/happy-dom/test/nodes/html-text-area-element/HTMLTextAreaElement.test.ts index 6ae44af1..4a84b55f 100644 --- a/packages/happy-dom/test/nodes/html-text-area-element/HTMLTextAreaElement.test.ts +++ b/packages/happy-dom/test/nodes/html-text-area-element/HTMLTextAreaElement.test.ts @@ -110,6 +110,10 @@ describe('HTMLTextAreaElement', () => { }); describe('get form()', () => { + it('Returns null if no parent form element exists.', () => { + expect(element.form).toBe(null); + }); + it('Returns parent form element.', () => { const form = document.createElement('form'); const div = document.createElement('div'); @@ -117,6 +121,27 @@ describe('HTMLTextAreaElement', () => { form.appendChild(div); expect(element.form).toBe(form); }); + + it('Returns form element by id if the form attribute is set when connecting node to DOM.', () => { + const form = document.createElement('form'); + form.id = 'form'; + document.body.appendChild(form); + element.setAttribute('form', 'form'); + expect(element.form).toBe(null); + document.body.appendChild(element); + expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); + }); + + it('Returns form element by id if the form attribute is set when element is connected to DOM.', () => { + const form = document.createElement('form'); + form.id = 'form'; + document.body.appendChild(form); + document.body.appendChild(element); + element.setAttribute('form', 'form'); + expect(element.form).toBe(form); + expect(form.elements.includes(element)).toBe(true); + }); }); for (const property of ['disabled', 'autofocus', 'required', 'readOnly']) { diff --git a/packages/happy-dom/tsconfig.json b/packages/happy-dom/tsconfig.json index 86c14613..02f73ca2 100644 --- a/packages/happy-dom/tsconfig.json +++ b/packages/happy-dom/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "lib", "rootDir": "src", - "target": "ES2020", + "target": "ES2022", "declaration": true, "declarationMap": true, "module": "Node16", @@ -21,7 +21,7 @@ "composite": false, "incremental": false, "lib": [ - "es2020" + "ES2022" ], "types": [ "node" diff --git a/packages/jest-environment/test/tsconfig.json b/packages/jest-environment/test/tsconfig.json index 52d8f026..00d32d3a 100644 --- a/packages/jest-environment/test/tsconfig.json +++ b/packages/jest-environment/test/tsconfig.json @@ -3,7 +3,7 @@ "outDir": "../tmp", "rootDir": ".", "tsBuildInfoFile": "../tmp/.tsbuildinfo-test", - "target": "es2020", + "target": "ES2022", "declaration": true, "module": "CommonJS", "moduleResolution": "node", @@ -25,7 +25,7 @@ "jest" ], "lib": [ - "es2020", + "ES2022", "dom" ] }, diff --git a/packages/jest-environment/tsconfig.json b/packages/jest-environment/tsconfig.json index 115a566c..7d417d2e 100644 --- a/packages/jest-environment/tsconfig.json +++ b/packages/jest-environment/tsconfig.json @@ -3,7 +3,7 @@ "outDir": "lib", "rootDir": "src", "tsBuildInfoFile": "tmp/.tsbuildinfo", - "target": "es2020", + "target": "ES2022", "declaration": true, "declarationMap": true, "module": "CommonJS", @@ -25,7 +25,7 @@ "node" ], "lib": [ - "es2020", + "ES2022", "dom" ] },