Skip to content

Commit

Permalink
feat: [#1623] Adds support for Window.scrollBy and Element.scrollBy
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
OlaviSau and capricorn86 authored Dec 30, 2024
1 parent c738c4e commit 5629a74
Show file tree
Hide file tree
Showing 5 changed files with 269 additions and 49 deletions.
76 changes: 53 additions & 23 deletions packages/happy-dom/src/nodes/element/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
(<number>this.scrollTop) = x.top;
}
if (x.left !== undefined) {
(<number>this.scrollLeft) = x.left;
}
});
} else {
if (x.top !== undefined) {
(<number>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);
(<number>this.scrollTop) = isNaN(top) ? 0 : top;
}
if (x.left !== undefined) {
(<number>this.scrollLeft) = x.left;
if (options.left !== undefined) {
const left = Number(options.left);
(<number>this.scrollLeft) = isNaN(left) ? 0 : left;
}
});
} else {
if (options.top !== undefined) {
const top = Number(options.top);
(<number>this.scrollTop) = isNaN(top) ? 0 : top;
}
if (options.left !== undefined) {
const left = Number(options.left);
(<number>this.scrollLeft) = isNaN(left) ? 0 : left;
}
} else if (x !== undefined && y !== undefined) {
(<number>this.scrollLeft) = x;
(<number>this.scrollTop) = y;
}
}

Expand All @@ -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.
*
Expand Down
76 changes: 53 additions & 23 deletions packages/happy-dom/src/window/BrowserWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
(<number>this.document.documentElement.scrollTop) = x.top;
}
if (x.left !== undefined) {
(<number>this.document.documentElement.scrollLeft) = x.left;
}
});
} else {
if (x.top !== undefined) {
(<number>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) {
(<number>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) {
(<number>this.document.documentElement.scrollLeft) = x;
(<number>this.document.documentElement.scrollTop) = y;
}
}

Expand All @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/happy-dom/src/window/IScrollToOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default interface IScrollToOptions {
top?: number;
left?: number;
behavior?: 'auto' | 'smooth';
}
103 changes: 100 additions & 3 deletions packages/happy-dom/test/nodes/element/Element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'."
)
);
});
});

Expand Down
58 changes: 58 additions & 0 deletions packages/happy-dom/test/window/BrowserWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 5629a74

Please sign in to comment.