Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [1623] Add missing scrollBy functionality #1624

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading