From 5629a74693e19b6a7661a4242b139bf8524c6247 Mon Sep 17 00:00:00 2001 From: Olavi Sau Date: Mon, 30 Dec 2024 21:43:17 +0200 Subject: [PATCH] feat: [#1623] Adds support for Window.scrollBy and Element.scrollBy * feat: [1623] Add missing scrollBy functionality * chore: [#1623] Adds unit tests and fixes reversed x and y value for scrollBy * chore: [#1623] Adds support for scrollBy on Window --------- Co-authored-by: David Ortner --- .../happy-dom/src/nodes/element/Element.ts | 76 +++++++++---- .../happy-dom/src/window/BrowserWindow.ts | 76 +++++++++---- .../happy-dom/src/window/IScrollToOptions.ts | 5 + .../test/nodes/element/Element.test.ts | 103 +++++++++++++++++- .../test/window/BrowserWindow.test.ts | 58 ++++++++++ 5 files changed, 269 insertions(+), 49 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 039652b8..cf44ffee 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -32,6 +32,7 @@ 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'; @@ -1168,28 +1169,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,13 +1207,35 @@ 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 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[PropertySymbol.window].TypeError( + "Failed to execute 'scrollBy' on 'Element': The provided value is not of type 'ScrollToOptions'." + ); + } + const options = typeof x === 'object' ? x : { left: x, top: y }; + 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. * 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 9374ced1..d6fc9548 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 behavior.', 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 behavior.', 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'." + ) + ); }); }); 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);