From 8d02d8c9bc57b05c6c7fd3dfe19d92a7783caeb7 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 26 Oct 2022 01:34:34 +0200 Subject: [PATCH] #637@patch: Fixes problem with HTMLSelectElement.selectedIndex not reflecting the select attribute set on options. --- .../happy-dom/src/nodes/element/Element.ts | 18 ++- .../happy-dom/src/nodes/element/IElement.ts | 6 +- .../src/nodes/html-element/HTMLElement.ts | 15 +- .../html-option-element/HTMLOptionElement.ts | 104 +++++++++----- .../HTMLOptionsCollection.ts | 11 +- .../html-select-element/HTMLSelectElement.ts | 136 ++++++++++++++---- .../src/nodes/svg-element/SVGElement.ts | 11 +- .../HTMLSelectElement.test.ts | 20 +++ 8 files changed, 225 insertions(+), 96 deletions(-) diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 0e90932d0..a7f3140dc 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -919,8 +919,17 @@ export default class Element extends Node implements IElement { * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - public removeAttributeNode(attribute: IAttr): void { + public removeAttributeNode(attribute: IAttr): IAttr { + const removedAttribute = this._attributes[attribute.name]; + + if (removedAttribute !== attribute) { + throw new DOMException( + `Failed to execute 'removeAttributeNode' on 'Element': The node provided is owned by another element.` + ); + } + delete this._attributes[attribute.name]; if (this.isConnected) { @@ -954,15 +963,18 @@ export default class Element extends Node implements IElement { } } } + + return attribute; } /** * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - public removeAttributeNodeNS(attribute: IAttr): void { - this.removeAttributeNode(attribute); + public removeAttributeNodeNS(attribute: IAttr): IAttr { + return this.removeAttributeNode(attribute); } /** diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts index 7649cd0bf..b3fc6ff57 100644 --- a/packages/happy-dom/src/nodes/element/IElement.ts +++ b/packages/happy-dom/src/nodes/element/IElement.ts @@ -260,15 +260,17 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode, * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - removeAttributeNode(attribute: IAttr): void; + removeAttributeNode(attribute: IAttr): IAttr; /** * Removes an Attr node. * * @param attribute Attribute. + * @returns Removed attribute. */ - removeAttributeNodeNS(attribute: IAttr): void; + removeAttributeNodeNS(attribute: IAttr): IAttr; /** * Clones a node. diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index bb1a93872..262eb88a5 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -421,11 +421,7 @@ export default class HTMLElement extends Element implements IHTMLElement { } /** - * The setAttributeNode() method adds a new Attr node to the specified element. - * * @override - * @param attribute Attribute. - * @returns Replaced attribute. */ public setAttributeNode(attribute: IAttr): IAttr { const replacedAttribute = super.setAttributeNode(attribute); @@ -438,25 +434,20 @@ export default class HTMLElement extends Element implements IHTMLElement { } /** - * Removes an Attr node. - * * @override - * @param attribute Attribute. */ - public removeAttributeNode(attribute: IAttr): void { + public removeAttributeNode(attribute: IAttr): IAttr { super.removeAttributeNode(attribute); if (attribute.name === 'style' && this._style) { this._style.cssText = ''; } + + return attribute; } /** - * Clones a node. - * * @override - * @param [deep=false] "true" to clone deep. - * @returns Cloned node. */ public cloneNode(deep = false): IHTMLElement { const clone = super.cloneNode(deep); 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 0f1dbdae7..a692e5e00 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts @@ -1,7 +1,8 @@ +import IAttr from '../attr/IAttr'; import HTMLElement from '../html-element/HTMLElement'; import IHTMLElement from '../html-element/IHTMLElement'; import IHTMLFormElement from '../html-form-element/IHTMLFormElement'; -import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement'; +import HTMLSelectElement from '../html-select-element/HTMLSelectElement'; import IHTMLOptionElement from './IHTMLOptionElement'; /** @@ -12,6 +13,8 @@ import IHTMLOptionElement from './IHTMLOptionElement'; */ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptionElement { public _index: number; + public _selectedness = false; + public _dirtyness = false; /** * Returns inner text, which is the rendered appearance of text. @@ -59,20 +62,7 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @returns Selected. */ public get selected(): boolean { - const parentNode = this.parentNode; - - if (parentNode?.tagName === 'SELECT') { - let index = -1; - for (let i = 0; i < parentNode.options.length; i++) { - if (parentNode.options[i] === this) { - index = i; - break; - } - } - return index !== -1 && parentNode.options.selectedIndex === index; - } - - return false; + return this._selectedness; } /** @@ -81,26 +71,13 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio * @param selected Selected. */ public set selected(selected: boolean) { - const parentNode = this.parentNode; - if (parentNode?.tagName === 'SELECT') { - if (selected) { - let index = -1; - - for (let i = 0; i < parentNode.options.length; i++) { - if (parentNode.options[i] === this) { - index = i; - break; - } - } - - if (index !== -1) { - parentNode.options.selectedIndex = index; - } - } else if (parentNode.options.length) { - parentNode.options.selectedIndex = 0; - } else { - parentNode.options.selectedIndex = -1; - } + const selectElement = this._getSelectElement(); + + this._dirtyness = true; + this._selectedness = Boolean(selected); + + if (selectElement) { + selectElement._resetOptionSelectednes(this._selectedness ? this : null); } } @@ -143,4 +120,61 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio public set value(value: string) { this.setAttributeNS(null, 'value', value); } + + /** + * @override + */ + public setAttributeNode(attribute: IAttr): IAttr { + const replacedAttribute = super.setAttributeNode(attribute); + + if ( + !this._dirtyness && + attribute.name === 'selected' && + replacedAttribute?.value !== attribute.value + ) { + const selectElement = this._getSelectElement(); + + this._selectedness = true; + + if (selectElement) { + selectElement._resetOptionSelectednes(this); + } + } + + return replacedAttribute; + } + + /** + * @override + */ + public removeAttributeNode(attribute: IAttr): IAttr { + super.removeAttributeNode(attribute); + + if (!this._dirtyness && attribute.name === 'selected') { + const selectElement = this._getSelectElement(); + + this._selectedness = false; + + if (selectElement) { + selectElement._resetOptionSelectednes(); + } + } + + return attribute; + } + + /** + * Returns select element. + * + * @returns Select element. + */ + private _getSelectElement(): HTMLSelectElement { + const parentNode = this.parentNode; + if (parentNode?.tagName === 'SELECT') { + return parentNode; + } + if ((parentNode?.parentNode)?.tagName === 'SELECT') { + return parentNode.parentNode; + } + } } diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts index 2d81159c0..8226dde2a 100644 --- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts +++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts @@ -16,7 +16,6 @@ export default class HTMLOptionsCollection implements IHTMLOptionsCollection { private _selectElement: IHTMLSelectElement; - private _selectedIndex = -1; /** * @@ -34,7 +33,7 @@ export default class HTMLOptionsCollection * @returns SelectedIndex. */ public get selectedIndex(): number { - return this._selectedIndex; + return this._selectElement.selectedIndex; } /** @@ -43,13 +42,7 @@ export default class HTMLOptionsCollection * @param selectedIndex SelectedIndex. */ public set selectedIndex(selectedIndex: number) { - if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { - if (selectedIndex >= 0 && selectedIndex < this.length) { - this._selectedIndex = selectedIndex; - } else { - this._selectedIndex = -1; - } - } + this._selectElement.selectedIndex = selectedIndex; } /** 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 b4c0e13a3..b7437746c 100644 --- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts +++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts @@ -167,13 +167,14 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Value. */ public get value(): string { - if (this.options.selectedIndex === -1) { - return ''; + for (let i = 0, max = this.options.length; i < max; i++) { + const option = this.options[i]; + if (option._selectedness) { + return option.value; + } } - const option = this.options[this.options.selectedIndex]; - - return option instanceof HTMLOptionElement ? option.value : ''; + return ''; } /** @@ -182,9 +183,15 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @param value Value. */ public set value(value: string) { - this.options.selectedIndex = this.options.findIndex( - (o) => o instanceof HTMLOptionElement && o.value === value - ); + for (let i = 0, max = this.options.length; i < max; i++) { + const option = this.options[i]; + if (option.value === value) { + option._selectedness = true; + option._dirtyness = true; + } else { + option._selectedness = false; + } + } } /** @@ -193,16 +200,31 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec * @returns Value. */ public get selectedIndex(): number { - return this.options.selectedIndex; + for (let i = 0, max = this.options.length; i < max; i++) { + if ((this.options[i])._selectedness) { + return i; + } + } + return -1; } /** * Sets value. * - * @param value Value. + * @param selectedIndex Selected index. */ - public set selectedIndex(value: number) { - this.options.selectedIndex = value; + public set selectedIndex(selectedIndex: number) { + if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) { + for (let i = 0, max = this.options.length; i < max; i++) { + (this.options[i])._selectedness = false; + } + + const selectedOption = this.options[selectedIndex]; + if (selectedOption) { + selectedOption._selectedness = true; + selectedOption._dirtyness = true; + } + } } /** @@ -287,13 +309,10 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec if (element.tagName === 'OPTION' || element.tagName === 'OPTGROUP') { this.options.push(element); - - if (this.options.length === 1) { - this.options.selectedIndex = 0; - } } this._updateIndexProperties(previousLength, this.options.length); + this._resetOptionSelectednes(); } return super.appendChild(node); @@ -332,15 +351,15 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec } else { this.options.push(newElement); } - - if (this.options.length === 1) { - this.options.selectedIndex = 0; - } } this._updateIndexProperties(previousLength, this.options.length); } + if (newNode.nodeType === NodeTypeEnum.elementNode) { + this._resetOptionSelectednes(); + } + return returnValue; } @@ -358,20 +377,83 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec if (index !== -1) { this.options.splice(index, 1); } + } - if (this.options.selectedIndex >= this.options.length) { - this.options.selectedIndex = this.options.length - 1; + this._updateIndexProperties(previousLength, this.options.length); + this._resetOptionSelectednes(); + } + + return super.removeChild(node); + } + + /** + * Resets the option selectedness. + * + * Based on: + * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/HTMLSelectElement-impl.js + * + * @param [newOption] Optional new option element to be selected. + * @see https://html.spec.whatwg.org/multipage/form-elements.html#selectedness-setting-algorithm + */ + public _resetOptionSelectednes(newOption?: IHTMLOptionElement): void { + if (this.hasAttributeNS(null, 'multiple')) { + return; + } + + const selected: HTMLOptionElement[] = []; + + for (let i = 0, max = this.options.length; i < max; i++) { + if (newOption) { + (this.options[i])._selectedness = this.options[i] === newOption; + } + + if ((this.options[i])._selectedness) { + selected.push(this.options[i]); + } + } + + const size = this._getDisplaySize(); + + if (size === 1 && !selected.length) { + for (let i = 0, max = this.options.length; i < max; i++) { + const option = this.options[i]; + + let disabled = option.hasAttributeNS(null, 'disabled'); + const parentNode = option.parentNode; + if ( + parentNode && + parentNode.nodeType === NodeTypeEnum.elementNode && + parentNode.tagName === 'OPTGROUP' && + parentNode.hasAttributeNS(null, 'disabled') + ) { + disabled = true; } - if (!this.options.length) { - this.options.selectedIndex = -1; + if (!disabled) { + option._selectedness = true; + break; } } - - this._updateIndexProperties(previousLength, this.options.length); + } else if (selected.length >= 2) { + for (let i = 0, max = this.options.length; i < max; i++) { + (this.options[i])._selectedness = i === selected.length - 1; + } } + } - return super.removeChild(node); + /** + * Returns display size. + * + * @returns Display size. + */ + protected _getDisplaySize(): number { + if (this.hasAttributeNS(null, 'size')) { + const size = parseInt(this.getAttributeNS(null, 'size')); + if (!isNaN(size) && size >= 0) { + return size; + } + } + return this.hasAttributeNS(null, 'multiple') ? 4 : 1; } /** diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts index 12136ecbd..664e0c10d 100644 --- a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts +++ b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts @@ -74,11 +74,7 @@ export default class SVGElement extends Element implements ISVGElement { } /** - * The setAttributeNode() method adds a new Attr node to the specified element. - * * @override - * @param attribute Attribute. - * @returns Replaced attribute. */ public setAttributeNode(attribute: IAttr): IAttr { const replacedAttribute = super.setAttributeNode(attribute); @@ -91,16 +87,15 @@ export default class SVGElement extends Element implements ISVGElement { } /** - * Removes an Attr node. - * * @override - * @param attribute Attribute. */ - public removeAttributeNode(attribute: IAttr): void { + public removeAttributeNode(attribute: IAttr): IAttr { super.removeAttributeNode(attribute); if (attribute.name === 'style' && this._style) { this._style.cssText = ''; } + + return attribute; } } diff --git a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts index f7705efdd..d86ebc6b9 100644 --- a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts +++ b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts @@ -137,6 +137,26 @@ describe('HTMLSelectElement', () => { element.options.selectedIndex = 1; expect(element.selectedIndex).toBe(1); }); + + it('Returns option with "selected" attribute is defined.', () => { + const option1 = document.createElement('option'); + const option2 = document.createElement('option'); + + option2.setAttribute('selected', ''); + + element.appendChild(option1); + element.appendChild(option2); + + expect(element.selectedIndex).toBe(1); + + option1.setAttribute('selected', ''); + + expect(element.selectedIndex).toBe(0); + + option2.removeAttribute('selected'); + + expect(element.selectedIndex).toBe(0); + }); }); describe(`set selectedIndex()`, () => {