From 108467d1a5ab6d122227579adb4e1f0e1e8d4787 Mon Sep 17 00:00:00 2001 From: Olavi Sau Date: Mon, 25 Nov 2024 13:11:11 +0200 Subject: [PATCH 1/3] feat: [1623] Add missing scrollBy functionality --- .../happy-dom/src/nodes/element/Element.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index d22cfd4a..1cf2e060 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -1228,6 +1228,29 @@ export default class Element this.scroll(x, y); } + /** + * Scrolls by a relative amount from the current position. + * + * @param topOrOptions pixels to scroll by from top or scroll options object. + * @param left pixels to scroll by from left. + */ + public scrollBy( + topOrOptions: { top?: number; left?: number; behavior?: string } | number, + left?: number + ): void { + if (typeof topOrOptions !== 'object' && arguments.length === 1) { + throw new TypeError( + "Failed to execute 'scrollBy' on 'Element': The provided value is not of type 'ScrollToOptions'." + ); + } + const options = typeof topOrOptions === 'object' ? topOrOptions : { left, top: topOrOptions }; + this.scroll({ + left: this.scrollLeft + (options.left ?? 0), + top: this.scrollTop + (options.top ?? 0), + behavior: options.behavior + }); + } + /** * Scrolls the element's ancestor containers such that the element on which scrollIntoView() is called is visible to the user. * From ce567c7ae4b323d2424126a4d376a2767dc2b88f Mon Sep 17 00:00:00 2001 From: David Ortner Date: Mon, 30 Dec 2024 20:19:16 +0100 Subject: [PATCH 2/3] chore: [#1623] Adds unit tests and fixes reversed x and y value for scrollBy --- .../happy-dom/src/nodes/element/Element.ts | 76 +++++++------ .../test/nodes/element/Element.test.ts | 103 +++++++++++++++++- 2 files changed, 144 insertions(+), 35 deletions(-) diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 750fc792..713b4460 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -35,6 +35,12 @@ import HTMLParser from '../../html-parser/HTMLParser.js'; type InsertAdjacentPosition = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'; +interface IScrollToOptions { + top?: number; + left?: number; + behavior?: 'auto' | 'smooth'; +} + /** * Element. */ @@ -1168,28 +1174,35 @@ export default class Element * @param x X position or options object. * @param y Y position. */ - public scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void { - if (typeof x === 'object') { - if (x.behavior === 'smooth') { - this[PropertySymbol.window].setTimeout(() => { - if (x.top !== undefined) { - (this.scrollTop) = x.top; - } - if (x.left !== undefined) { - (this.scrollLeft) = x.left; - } - }); - } else { - if (x.top !== undefined) { - (this.scrollTop) = x.top; + public scroll(x: IScrollToOptions | number, y?: number): void { + if (typeof x !== 'object' && arguments.length === 1) { + throw new this[PropertySymbol.window].TypeError( + "Failed to execute 'scroll' on 'Element': The provided value is not of type 'ScrollToOptions'." + ); + } + + const options = typeof x === 'object' ? x : { left: x, top: y }; + + if (options.behavior === 'smooth') { + this[PropertySymbol.window].setTimeout(() => { + if (options.top !== undefined) { + const top = Number(options.top); + (this.scrollTop) = isNaN(top) ? 0 : top; } - if (x.left !== undefined) { - (this.scrollLeft) = x.left; + if (options.left !== undefined) { + const left = Number(options.left); + (this.scrollLeft) = isNaN(left) ? 0 : left; } + }); + } else { + if (options.top !== undefined) { + const top = Number(options.top); + (this.scrollTop) = isNaN(top) ? 0 : top; + } + if (options.left !== undefined) { + const left = Number(options.left); + (this.scrollLeft) = isNaN(left) ? 0 : left; } - } else if (x !== undefined && y !== undefined) { - (this.scrollLeft) = x; - (this.scrollTop) = y; } } @@ -1199,29 +1212,28 @@ export default class Element * @param x X position or options object. * @param y Y position. */ - public scrollTo( - x: { top?: number; left?: number; behavior?: string } | number, - y?: number - ): void { + public scrollTo(x: IScrollToOptions | number, y?: number): void { + if (typeof x !== 'object' && arguments.length === 1) { + throw new this[PropertySymbol.window].TypeError( + "Failed to execute 'scrollTo' on 'Element': The provided value is not of type 'ScrollToOptions'." + ); + } this.scroll(x, y); } /** * Scrolls by a relative amount from the current position. * - * @param topOrOptions pixels to scroll by from top or scroll options object. - * @param left pixels to scroll by from left. + * @param x Pixels to scroll by from top or scroll options object. + * @param y Pixels to scroll by from left. */ - public scrollBy( - topOrOptions: { top?: number; left?: number; behavior?: string } | number, - left?: number - ): void { - if (typeof topOrOptions !== 'object' && arguments.length === 1) { - throw new TypeError( + public scrollBy(x: IScrollToOptions | number, y?: number): void { + if (typeof x !== 'object' && arguments.length === 1) { + throw new this[PropertySymbol.window].TypeError( "Failed to execute 'scrollBy' on 'Element': The provided value is not of type 'ScrollToOptions'." ); } - const options = typeof topOrOptions === 'object' ? topOrOptions : { left, top: topOrOptions }; + const options = typeof x === 'object' ? x : { left: x, top: y }; this.scroll({ left: this.scrollLeft + (options.left ?? 0), top: this.scrollTop + (options.top ?? 0), diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 9374ced1..75ca71d4 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -2060,12 +2060,109 @@ describe('Element', () => { }); }); - describe('scroll()', () => { - it('Sets the properties "scrollTop" and "scrollLeft".', () => { + for (const functionName of ['scroll', 'scrollTo']) { + describe(`${functionName}()`, () => { + it('Sets the properties "scrollTop" and "scrollLeft".', () => { + const div = document.createElement('div'); + + div.scrollLeft = 10; + div.scrollTop = 15; + + div[functionName](20, 30); + + expect(div.scrollLeft).toBe(20); + expect(div.scrollTop).toBe(30); + }); + + it('Sets the properties "scrollTop" and "scrollLeft" using an object.', () => { + const div = document.createElement('div'); + + div.scrollLeft = 10; + div.scrollTop = 15; + + div[functionName]({ left: 20, top: 30 }); + + expect(div.scrollLeft).toBe(20); + expect(div.scrollTop).toBe(30); + }); + + it('Supports smooth scrolling.', async () => { + const div = document.createElement('div'); + + div.scrollLeft = 10; + div.scrollTop = 15; + + div[functionName]({ left: 20, top: 30, behavior: 'smooth' }); + + expect(div.scrollLeft).toBe(10); + expect(div.scrollTop).toBe(15); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(div.scrollLeft).toBe(20); + expect(div.scrollTop).toBe(30); + }); + + it('Throws an exception if the there is only one argument and it is not an object.', () => { + const div = document.createElement('div'); + expect(() => div[functionName](10)).toThrow( + new TypeError( + `Failed to execute '${functionName}' on 'Element': The provided value is not of type 'ScrollToOptions'.` + ) + ); + }); + }); + } + + describe('scrollBy()', () => { + it('Appends to the properties "scrollTop" and "scrollLeft" using numbers.', () => { + const div = document.createElement('div'); + + div.scrollLeft = 10; + div.scrollTop = 15; + + div.scrollBy(10, 15); + + expect(div.scrollLeft).toBe(20); + expect(div.scrollTop).toBe(30); + }); + + it('Appends to the properties "scrollTop" and "scrollLeft" using an object.', () => { + const div = document.createElement('div'); + + div.scrollLeft = 10; + div.scrollTop = 15; + + div.scrollBy({ left: 10, top: 15 }); + + expect(div.scrollLeft).toBe(20); + expect(div.scrollTop).toBe(30); + }); + + it('Supports smooth scrolling.', async () => { const div = document.createElement('div'); - div.scroll(10, 15); + + div.scrollLeft = 10; + div.scrollTop = 15; + + div.scrollBy({ left: 10, top: 15, behavior: 'smooth' }); + expect(div.scrollLeft).toBe(10); expect(div.scrollTop).toBe(15); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(div.scrollLeft).toBe(20); + expect(div.scrollTop).toBe(30); + }); + + it('Throws an exception if the there is only one argument and it is not an object.', () => { + const div = document.createElement('div'); + expect(() => div.scrollBy(10)).toThrow( + new TypeError( + "Failed to execute 'scrollBy' on 'Element': The provided value is not of type 'ScrollToOptions'." + ) + ); }); }); From c61930056354b246cfebcee5563912204246e5b7 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Mon, 30 Dec 2024 20:39:21 +0100 Subject: [PATCH 3/3] chore: [#1623] Adds support for scrollBy on Window --- .../happy-dom/src/nodes/element/Element.ts | 7 +- .../happy-dom/src/window/BrowserWindow.ts | 76 +++++++++++++------ .../happy-dom/src/window/IScrollToOptions.ts | 5 ++ .../test/nodes/element/Element.test.ts | 4 +- .../test/window/BrowserWindow.test.ts | 58 ++++++++++++++ 5 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 packages/happy-dom/src/window/IScrollToOptions.ts diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 713b4460..cf44ffee 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -32,15 +32,10 @@ import NamedNodeMapProxyFactory from './NamedNodeMapProxyFactory.js'; import NodeFactory from '../NodeFactory.js'; import HTMLSerializer from '../../html-serializer/HTMLSerializer.js'; import HTMLParser from '../../html-parser/HTMLParser.js'; +import IScrollToOptions from '../../window/IScrollToOptions.js'; type InsertAdjacentPosition = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'; -interface IScrollToOptions { - top?: number; - left?: number; - behavior?: 'auto' | 'smooth'; -} - /** * Element. */ diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts index ccdc8fe5..85e862ce 100644 --- a/packages/happy-dom/src/window/BrowserWindow.ts +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -306,6 +306,7 @@ import SVGUnitTypes from '../svg/SVGUnitTypes.js'; import DOMPoint from '../dom/DOMPoint.js'; import SVGAnimatedLengthList from '../svg/SVGAnimatedLengthList.js'; import CustomElementReactionStack from '../custom-element/CustomElementReactionStack.js'; +import IScrollToOptions from './IScrollToOptions.js'; const TIMER = { setTimeout: globalThis.setTimeout.bind(globalThis), @@ -1144,28 +1145,35 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal * @param x X position or options object. * @param y Y position. */ - public scroll(x: { top?: number; left?: number; behavior?: string } | number, y?: number): void { - if (typeof x === 'object') { - if (x.behavior === 'smooth') { - this.setTimeout(() => { - if (x.top !== undefined) { - (this.document.documentElement.scrollTop) = x.top; - } - if (x.left !== undefined) { - (this.document.documentElement.scrollLeft) = x.left; - } - }); - } else { - if (x.top !== undefined) { - (this.document.documentElement.scrollTop) = x.top; + public scroll(x: IScrollToOptions | number, y?: number): void { + if (typeof x !== 'object' && arguments.length === 1) { + throw new this.TypeError( + "Failed to execute 'scroll' on 'Window': The provided value is not of type 'ScrollToOptions'." + ); + } + + const options = typeof x === 'object' ? x : { left: x, top: y }; + + if (options.behavior === 'smooth') { + this.setTimeout(() => { + if (options.top !== undefined) { + const top = Number(options.top); + this.document.documentElement.scrollTop = isNaN(top) ? 0 : top; } - if (x.left !== undefined) { - (this.document.documentElement.scrollLeft) = x.left; + if (options.left !== undefined) { + const left = Number(options.left); + this.document.documentElement.scrollLeft = isNaN(left) ? 0 : left; } + }); + } else { + if (options.top !== undefined) { + const top = Number(options.top); + this.document.documentElement.scrollTop = isNaN(top) ? 0 : top; + } + if (options.left !== undefined) { + const left = Number(options.left); + this.document.documentElement.scrollLeft = isNaN(left) ? 0 : left; } - } else if (x !== undefined && y !== undefined) { - (this.document.documentElement.scrollLeft) = x; - (this.document.documentElement.scrollTop) = y; } } @@ -1175,13 +1183,35 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal * @param x X position or options object. * @param y Y position. */ - public scrollTo( - x: { top?: number; left?: number; behavior?: string } | number, - y?: number - ): void { + public scrollTo(x: IScrollToOptions | number, y?: number): void { + if (typeof x !== 'object' && arguments.length === 1) { + throw new this.TypeError( + "Failed to execute 'scrollTo' on 'Window': The provided value is not of type 'ScrollToOptions'." + ); + } this.scroll(x, y); } + /** + * Scrolls by a relative amount from the current position. + * + * @param x Pixels to scroll by from top or scroll options object. + * @param y Pixels to scroll by from left. + */ + public scrollBy(x: IScrollToOptions | number, y?: number): void { + if (typeof x !== 'object' && arguments.length === 1) { + throw new this.TypeError( + "Failed to execute 'scrollBy' on 'Window': The provided value is not of type 'ScrollToOptions'." + ); + } + const options = typeof x === 'object' ? x : { left: x, top: y }; + this.scroll({ + left: this.document.documentElement.scrollLeft + (options.left ?? 0), + top: this.document.documentElement.scrollTop + (options.top ?? 0), + behavior: options.behavior + }); + } + /** * Shifts focus away from the window. */ diff --git a/packages/happy-dom/src/window/IScrollToOptions.ts b/packages/happy-dom/src/window/IScrollToOptions.ts new file mode 100644 index 00000000..7f641689 --- /dev/null +++ b/packages/happy-dom/src/window/IScrollToOptions.ts @@ -0,0 +1,5 @@ +export default interface IScrollToOptions { + top?: number; + left?: number; + behavior?: 'auto' | 'smooth'; +} diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts index 75ca71d4..d6fc9548 100644 --- a/packages/happy-dom/test/nodes/element/Element.test.ts +++ b/packages/happy-dom/test/nodes/element/Element.test.ts @@ -2086,7 +2086,7 @@ describe('Element', () => { expect(div.scrollTop).toBe(30); }); - it('Supports smooth scrolling.', async () => { + it('Supports smooth behavior.', async () => { const div = document.createElement('div'); div.scrollLeft = 10; @@ -2139,7 +2139,7 @@ describe('Element', () => { expect(div.scrollTop).toBe(30); }); - it('Supports smooth scrolling.', async () => { + it('Supports smooth behavior.', async () => { const div = document.createElement('div'); div.scrollLeft = 10; diff --git a/packages/happy-dom/test/window/BrowserWindow.test.ts b/packages/happy-dom/test/window/BrowserWindow.test.ts index d94c3e45..1377d7fe 100644 --- a/packages/happy-dom/test/window/BrowserWindow.test.ts +++ b/packages/happy-dom/test/window/BrowserWindow.test.ts @@ -1475,9 +1475,67 @@ describe('BrowserWindow', () => { expect(window.scrollX).toBe(50); expect(window.scrollY).toBe(60); }); + + it('Throws an exception if the there is only one argument and it is not an object.', () => { + expect(() => window[functionName](10)).toThrow( + new TypeError( + `Failed to execute '${functionName}' on 'Window': The provided value is not of type 'ScrollToOptions'.` + ) + ); + }); }); } + describe('scrollBy()', () => { + it('Append the values to the current scroll position.', () => { + window.scroll(50, 60); + window.scrollBy(10, 20); + expect(window.document.documentElement.scrollLeft).toBe(60); + expect(window.document.documentElement.scrollTop).toBe(80); + expect(window.pageXOffset).toBe(60); + expect(window.pageYOffset).toBe(80); + expect(window.scrollX).toBe(60); + expect(window.scrollY).toBe(80); + }); + + it('Append the values to the current scroll position with object.', () => { + window.scroll(50, 60); + window.scrollBy({ left: 10, top: 20 }); + expect(window.document.documentElement.scrollLeft).toBe(60); + expect(window.document.documentElement.scrollTop).toBe(80); + expect(window.pageXOffset).toBe(60); + expect(window.pageYOffset).toBe(80); + expect(window.scrollX).toBe(60); + expect(window.scrollY).toBe(80); + }); + + it('Supports smooth behavior.', async () => { + window.scroll(50, 60); + window.scrollBy({ left: 10, top: 20, behavior: 'smooth' }); + expect(window.document.documentElement.scrollLeft).toBe(50); + expect(window.document.documentElement.scrollTop).toBe(60); + expect(window.pageXOffset).toBe(50); + expect(window.pageYOffset).toBe(60); + expect(window.scrollX).toBe(50); + expect(window.scrollY).toBe(60); + await browserFrame.waitUntilComplete(); + expect(window.document.documentElement.scrollLeft).toBe(60); + expect(window.document.documentElement.scrollTop).toBe(80); + expect(window.pageXOffset).toBe(60); + expect(window.pageYOffset).toBe(80); + expect(window.scrollX).toBe(60); + expect(window.scrollY).toBe(80); + }); + + it('Throws an exception if the there is only one argument and it is not an object.', () => { + expect(() => window.scrollBy(10)).toThrow( + new TypeError( + "Failed to execute 'scrollBy' on 'Window': The provided value is not of type 'ScrollToOptions'." + ) + ); + }); + }); + describe('getSelection()', () => { it('Returns selection.', () => { expect(window.getSelection() instanceof Selection).toBe(true);