From a0538558f63c915603e14cacc554d14a5eaadef8 Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Tue, 1 Oct 2024 11:09:41 +0200 Subject: [PATCH 1/9] Allow a single container --- src/index.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index e01e825..bd3f90f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ export default class ScrollMirror { constructor( elements: NodeListOf | Element[], - options: Partial = {}, + options: Partial = {} ) { this.elements = [...elements] .filter(Boolean) @@ -43,7 +43,7 @@ export default class ScrollMirror { this.options = { ...this.defaults, ...options }; - if (!this.validateElements()) return; + this.validateElements(); this.elements.forEach((element) => this.addHandler(element)); /** @@ -53,7 +53,7 @@ export default class ScrollMirror { if (this.elements.includes(document.documentElement)) { this.mirrorScrollPositions( this.getScrollProgress(document.documentElement), - document.documentElement, + document.documentElement ); } } @@ -74,17 +74,18 @@ export default class ScrollMirror { } /** Make sure the provided elements are valid @internal */ - validateElements(): boolean { + validateElements(): void { const elements = [...this.elements]; + if (elements.length < 2) { - console.error(`${this.prefix} Please provide at least two elements`); - return false; + console.warn(`${this.prefix} Only one element provided.`, elements); + } + + if (elements.some((el) => !el)) { + console.error(`${this.prefix} some elements are not defined:`, elements); } + for (const element of elements) { - if (!element) { - console.warn(`${this.prefix} element is not defined:`, element); - return false; - } if (element instanceof HTMLElement && !hasOverflow(element)) { console.warn(`${this.prefix} element doesn't have overflow:`, element); } @@ -93,13 +94,9 @@ export default class ScrollMirror { element.matches("body *") && !hasCSSOverflow(element) ) { - console.warn( - `${this.prefix} no "overflow: auto;" or "overflow: scroll;" set on element:`, - element, - ); + console.warn(`${this.prefix} no "overflow: auto;" or "overflow: scroll;" set on element:`, element); // prettier-ignore } } - return true; } /** Add the scroll handler to the element @internal */ @@ -148,14 +145,14 @@ export default class ScrollMirror { this.mirrorScrollPositions( this.getScrollProgress(scrolledElement), - scrolledElement, + scrolledElement ); }; /** Mirror the scroll positions of all elements to a target @internal */ mirrorScrollPositions( progress: Progress, - ignore: HTMLElement | undefined = undefined, + ignore: HTMLElement | undefined = undefined ) { this.elements.forEach((element) => { /* Ignore the currently scrolled element */ From 6767c8d10f4645d94bb2fdff841760692c674913 Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Tue, 1 Oct 2024 11:15:24 +0200 Subject: [PATCH 2/9] Get the scroll progress from the first element that actually has overflow --- src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index bd3f90f..a30d1a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -196,7 +196,15 @@ export default class ScrollMirror { } /** Get the scroll progress of an element, between 0-1 */ - getScrollProgress(el: HTMLElement): Progress { + getScrollProgress(el: HTMLElement | undefined): Progress { + + if (el == null) { + return { + x: 0, + y: 0, + }; + } + const { scrollTop, scrollHeight, @@ -216,7 +224,9 @@ export default class ScrollMirror { } get progress(): Progress { - return this.getScrollProgress(this.elements[0]); + const firstWithOverflow = this.elements.find((el) => hasOverflow(el)); + + return this.getScrollProgress(firstWithOverflow); } /** From a28473381e788a0515781cb9730a3a49fd6d6ccc Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Tue, 1 Oct 2024 11:16:43 +0200 Subject: [PATCH 3/9] formatting --- src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index a30d1a1..2978a78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ export default class ScrollMirror { constructor( elements: NodeListOf | Element[], - options: Partial = {} + options: Partial = {}, ) { this.elements = [...elements] .filter(Boolean) @@ -53,7 +53,7 @@ export default class ScrollMirror { if (this.elements.includes(document.documentElement)) { this.mirrorScrollPositions( this.getScrollProgress(document.documentElement), - document.documentElement + document.documentElement, ); } } @@ -145,14 +145,14 @@ export default class ScrollMirror { this.mirrorScrollPositions( this.getScrollProgress(scrolledElement), - scrolledElement + scrolledElement, ); }; /** Mirror the scroll positions of all elements to a target @internal */ mirrorScrollPositions( progress: Progress, - ignore: HTMLElement | undefined = undefined + ignore: HTMLElement | undefined = undefined, ) { this.elements.forEach((element) => { /* Ignore the currently scrolled element */ @@ -197,7 +197,6 @@ export default class ScrollMirror { /** Get the scroll progress of an element, between 0-1 */ getScrollProgress(el: HTMLElement | undefined): Progress { - if (el == null) { return { x: 0, From 6cb5752ce7b846dc5d011c1847345e2e54d9ebee Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Tue, 1 Oct 2024 11:58:08 +0200 Subject: [PATCH 4/9] Also handle the case where no element was provided --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2978a78..e75d47b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,11 @@ export default class ScrollMirror { validateElements(): void { const elements = [...this.elements]; + if (elements.length < 1) { + console.warn(`${this.prefix} No elements provided.`); + return; + } + if (elements.length < 2) { console.warn(`${this.prefix} Only one element provided.`, elements); } From 2e6f816481fc952a7927c8333569de9275001f71 Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Tue, 1 Oct 2024 12:15:45 +0200 Subject: [PATCH 5/9] Export additional utility functions --- src/index.ts | 49 +++++++++++++------------------------------- src/support/devs.ts | 4 ++++ src/support/utils.ts | 29 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 35 deletions(-) create mode 100644 src/support/devs.ts diff --git a/src/index.ts b/src/index.ts index e75d47b..5599687 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -import { hasCSSOverflow, hasOverflow, nextTick } from "./support/utils.js"; +import type { Progress } from "./support/devs.js"; +import { + getScrollProgress, + hasCSSOverflow, + hasOverflow, + nextTick, +} from "./support/utils.js"; export type Options = { /** Mirror the vertical scroll position */ @@ -7,10 +13,10 @@ export type Options = { horizontal: boolean; }; -export type Progress = { - x: number; - y: number; -}; +/** + * Utility functions + */ +export { getScrollProgress, hasOverflow, hasCSSOverflow, nextTick }; /** * Mirrors the scroll position of multiple elements on a page @@ -52,7 +58,7 @@ export default class ScrollMirror { */ if (this.elements.includes(document.documentElement)) { this.mirrorScrollPositions( - this.getScrollProgress(document.documentElement), + getScrollProgress(document.documentElement), document.documentElement, ); } @@ -149,7 +155,7 @@ export default class ScrollMirror { await nextTick(); this.mirrorScrollPositions( - this.getScrollProgress(scrolledElement), + getScrollProgress(scrolledElement), scrolledElement, ); }; @@ -200,37 +206,10 @@ export default class ScrollMirror { } } - /** Get the scroll progress of an element, between 0-1 */ - getScrollProgress(el: HTMLElement | undefined): Progress { - if (el == null) { - return { - x: 0, - y: 0, - }; - } - - const { - scrollTop, - scrollHeight, - clientHeight, - scrollLeft, - scrollWidth, - clientWidth, - } = el; - - const availableWidth = scrollWidth - clientWidth; - const availableHeight = scrollHeight - clientHeight; - - return { - x: !!scrollLeft ? scrollLeft / Math.max(0.00001, availableWidth) : 0, - y: !!scrollTop ? scrollTop / Math.max(0.00001, availableHeight) : 0, - }; - } - get progress(): Progress { const firstWithOverflow = this.elements.find((el) => hasOverflow(el)); - return this.getScrollProgress(firstWithOverflow); + return getScrollProgress(firstWithOverflow); } /** diff --git a/src/support/devs.ts b/src/support/devs.ts new file mode 100644 index 0000000..adde417 --- /dev/null +++ b/src/support/devs.ts @@ -0,0 +1,4 @@ +export type Progress = { + x: number; + y: number; +}; diff --git a/src/support/utils.ts b/src/support/utils.ts index 067e47f..2c15955 100644 --- a/src/support/utils.ts +++ b/src/support/utils.ts @@ -1,3 +1,5 @@ +import type { Progress } from "./devs.js"; + /** Return a Promise that resolves after the next event loop. */ export const nextTick = (): Promise => { return new Promise((resolve) => { @@ -20,3 +22,30 @@ export const hasCSSOverflow = (element: HTMLElement) => { const overflow = window.getComputedStyle(element)["overflow"]; return overflow.includes("auto") || overflow.includes("scroll"); }; + +/** Get the scroll progress of an element, between 0-1 */ +export const getScrollProgress = (el: HTMLElement | undefined): Progress => { + if (el == null) { + return { + x: 0, + y: 0, + }; + } + + const { + scrollTop, + scrollHeight, + clientHeight, + scrollLeft, + scrollWidth, + clientWidth, + } = el; + + const availableWidth = scrollWidth - clientWidth; + const availableHeight = scrollHeight - clientHeight; + + return { + x: !!scrollLeft ? scrollLeft / Math.max(0.00001, availableWidth) : 0, + y: !!scrollTop ? scrollTop / Math.max(0.00001, availableHeight) : 0, + }; +}; From db78822c4a6cd38b1875728fafb752d000d7ff77 Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Tue, 1 Oct 2024 20:00:26 +0200 Subject: [PATCH 6/9] Optimize exports, add unit test for exports --- package.json | 20 +- pnpm-lock.yaml | 545 +++++++++++++++++++++++++++ src/ScrollMirror.ts | 240 ++++++++++++ src/index.ts | 250 +----------- src/support/defs.ts | 11 + src/support/devs.ts | 4 - src/support/{utils.ts => helpers.ts} | 2 +- tests/unit/tests/exports.test.ts | 27 ++ tests/unit/vitest.config.ts | 17 + tests/unit/vitest.setup.ts | 6 + 10 files changed, 864 insertions(+), 258 deletions(-) create mode 100644 src/ScrollMirror.ts create mode 100644 src/support/defs.ts delete mode 100644 src/support/devs.ts rename src/support/{utils.ts => helpers.ts} (96%) create mode 100644 tests/unit/tests/exports.test.ts create mode 100644 tests/unit/vitest.config.ts create mode 100644 tests/unit/vitest.setup.ts diff --git a/package.json b/package.json index d8db793..fc6d668 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,15 @@ "license": "ISC", "type": "module", "source": "./src/index.ts", - "main": "./dist/index.cjs", - "module": "./dist/index.module.js", - "unpkg": "./dist/index.umd.js", + "main": "./dist/ScrollMirror.cjs", + "module": "./dist/ScrollMirror.module.js", + "unpkg": "./dist/ScrollMirror.umd.js", "types": "./dist/types/index.d.ts", "exports": { ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.modern.js", - "require": "./dist/index.cjs" + "import": "./dist/ScrollMirror.modern.js", + "require": "./dist/ScrollMirror.cjs" } }, "files": [ @@ -38,12 +38,14 @@ "prepublishOnly": "pnpm run build", "build": "pnpm run clean && pnpm run build:module && pnpm run build:bundle", "build:module": "BROWSERSLIST_ENV=modern microbundle src/index.ts --format modern,esm,cjs", - "build:bundle": "BROWSERSLIST_ENV=production microbundle src/index.ts --format umd --external none", + "build:bundle": "BROWSERSLIST_ENV=production microbundle src/ScrollMirror.ts --format umd --external none", "watch": "BROWSERSLIST_ENV=development microbundle src/index.ts --watch --format modern", "docs:dev": "astro dev --root docs", "docs:build": "astro build --root docs", "docs:serve": "astro build --root docs && astro preview --root docs", - "test": "pnpm run test:e2e", + "test": "pnpm run test:unit && pnpm run test:e2e", + "test:unit": "vitest run --config ./tests/unit/vitest.config.ts", + "test:unit:watch": "vitest --config ./tests/unit/vitest.config.ts", "test:e2e": "pnpm exec playwright test --config ./tests/e2e/config.playwright.ts", "test:e2e:dev": "PLAYWRIGHT_ENV=dev pnpm run test:e2e --ui", "test:e2e:install": "pnpm exec playwright install --with-deps" @@ -54,8 +56,10 @@ "astro": "^4.15.9", "astro-expressive-code": "^0.37.0", "astro-feather": "^1.0.0", + "jsdom": "^25.0.1", "microbundle": "^0.15.1", "prettier": "^3.3.3", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "vitest": "^2.1.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e78f1a0..d3c7eaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: astro-feather: specifier: ^1.0.0 version: 1.0.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 microbundle: specifier: ^0.15.1 version: 0.15.1(@types/babel__core@7.20.5) @@ -32,6 +35,9 @@ importers: typescript: specifier: ^5.6.2 version: 5.6.2 + vitest: + specifier: ^2.1.1 + version: 2.1.1(@types/node@22.7.2)(jsdom@25.0.1)(terser@5.34.0) packages: @@ -1221,6 +1227,36 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@vitest/expect@2.1.1': + resolution: {integrity: sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==} + + '@vitest/mocker@2.1.1': + resolution: {integrity: sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==} + peerDependencies: + '@vitest/spy': 2.1.1 + msw: ^2.3.5 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.1': + resolution: {integrity: sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==} + + '@vitest/runner@2.1.1': + resolution: {integrity: sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==} + + '@vitest/snapshot@2.1.1': + resolution: {integrity: sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==} + + '@vitest/spy@2.1.1': + resolution: {integrity: sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==} + + '@vitest/utils@2.1.1': + resolution: {integrity: sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1231,6 +1267,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -1283,6 +1323,10 @@ packages: resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1303,6 +1347,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + asyncro@3.0.0: resolution: {integrity: sha512-nEnWYfrBmA3taTiuiOoZYmgJ/CNrSoQLeLs29SeLcPu60yaw/mHDBHV0iOZ051fTvsTHxpCY+gXibqT9wbQYfg==} @@ -1393,6 +1440,10 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -1418,6 +1469,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.1.1: + resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} + engines: {node: '>=12'} + chalk@1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -1446,6 +1501,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + ci-info@4.0.0: resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==} engines: {node: '>=8'} @@ -1496,6 +1555,10 @@ packages: colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1579,6 +1642,14 @@ packages: resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} engines: {node: '>=8.0.0'} + cssstyle@4.1.0: + resolution: {integrity: sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==} + engines: {node: '>=18'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -1600,9 +1671,16 @@ packages: supports-color: optional: true + decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1619,6 +1697,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1845,6 +1927,10 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -1890,6 +1976,9 @@ packages: resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} engines: {node: '>=18'} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -2023,6 +2112,10 @@ packages: hastscript@9.0.0: resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -2032,6 +2125,18 @@ packages: http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.5: + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + icss-replace-symbols@1.1.0: resolution: {integrity: sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==} @@ -2172,6 +2277,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -2240,6 +2348,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -2313,6 +2430,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.1.1: + resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2514,6 +2634,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -2569,6 +2697,9 @@ packages: resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} engines: {node: '>=0.10.0'} + nwsapi@2.2.13: + resolution: {integrity: sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2671,6 +2802,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -2958,6 +3096,10 @@ packages: property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3106,6 +3248,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3127,6 +3272,13 @@ packages: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -3162,6 +3314,9 @@ packages: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3205,6 +3360,12 @@ packages: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -3298,6 +3459,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + terser@5.34.0: resolution: {integrity: sha512-y5NUX+U9HhVsK/zihZwoq4r9dICLyV2jXGOriDAVOeKhq3LKVjgJbGO90FisozXLlJfvjHqgckGmJFBb9KYoWQ==} engines: {node: '>=10'} @@ -3306,9 +3470,31 @@ packages: tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.0: resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==} + tinypool@1.0.1: + resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.48: + resolution: {integrity: sha512-3gD9iKn/n2UuFH1uilBviK9gvTNT6iYwdqrj1Vr5mh8FuelvpRNaYVH4pNYqUgOGU4aAdL9X35eLuuj0gRsx+A==} + + tldts@6.1.48: + resolution: {integrity: sha512-SPbnh1zaSzi/OsmHb1vrPNnYuwJbdWjwo5TbBYYMlTtH3/1DSb41t8bcSxkwDmmbG2q6VLPVvQc7Yf23T+1EEw==} + hasBin: true + to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -3317,6 +3503,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.0.0: + resolution: {integrity: sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==} + engines: {node: '>=16'} + + tr46@5.0.0: + resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3443,6 +3637,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.1: + resolution: {integrity: sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite@5.4.8: resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3482,9 +3681,54 @@ packages: vite: optional: true + vitest@2.1.1: + resolution: {integrity: sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.1 + '@vitest/ui': 2.1.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.0.0: + resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} + engines: {node: '>=18'} + which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} @@ -3500,6 +3744,11 @@ packages: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@4.0.1: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} @@ -3515,6 +3764,25 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xxhash-wasm@1.0.2: resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} @@ -4924,12 +5192,58 @@ snapshots: '@ungap/structured-clone@1.2.0': {} + '@vitest/expect@2.1.1': + dependencies: + '@vitest/spy': 2.1.1 + '@vitest/utils': 2.1.1 + chai: 5.1.1 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.1(@vitest/spy@2.1.1)(vite@5.4.8(@types/node@22.7.2)(terser@5.34.0))': + dependencies: + '@vitest/spy': 2.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.11 + optionalDependencies: + vite: 5.4.8(@types/node@22.7.2)(terser@5.34.0) + + '@vitest/pretty-format@2.1.1': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.1': + dependencies: + '@vitest/utils': 2.1.1 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.1': + dependencies: + '@vitest/pretty-format': 2.1.1 + magic-string: 0.30.11 + pathe: 1.1.2 + + '@vitest/spy@2.1.1': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.1': + dependencies: + '@vitest/pretty-format': 2.1.1 + loupe: 3.1.1 + tinyrainbow: 1.2.0 + acorn-jsx@5.3.2(acorn@8.12.1): dependencies: acorn: 8.12.1 acorn@8.12.1: {} + agent-base@7.1.1: + dependencies: + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -4978,6 +5292,8 @@ snapshots: is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 + assertion-error@2.0.1: {} + astring@1.9.0: {} astro-expressive-code@0.37.0(astro@4.15.9(@types/node@22.7.2)(rollup@2.79.1)(terser@5.34.0)(typescript@5.6.2)): @@ -5071,6 +5387,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + asyncro@3.0.0: {} autoprefixer@10.4.20(postcss@8.4.47): @@ -5175,6 +5493,8 @@ snapshots: builtin-modules@3.3.0: {} + cac@6.7.14: {} + call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -5200,6 +5520,14 @@ snapshots: ccount@2.0.1: {} + chai@5.1.1: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.1 + pathval: 2.0.0 + chalk@1.1.3: dependencies: ansi-styles: 2.2.1 @@ -5229,6 +5557,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.1: {} + ci-info@4.0.0: {} cli-boxes@3.0.0: {} @@ -5275,6 +5605,10 @@ snapshots: colord@2.9.3: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@2.20.3: {} @@ -5378,6 +5712,15 @@ snapshots: dependencies: css-tree: 1.1.3 + cssstyle@4.1.0: + dependencies: + rrweb-cssom: 0.7.1 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.7 @@ -5400,10 +5743,14 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.4.3: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deepmerge@4.3.1: {} define-data-property@1.1.4: @@ -5420,6 +5767,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.0.3: @@ -5700,6 +6049,12 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fraction.js@4.3.7: {} fs-extra@10.1.0: @@ -5737,6 +6092,8 @@ snapshots: get-east-asian-width@1.2.0: {} + get-func-name@2.0.2: {} + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -5987,12 +6344,34 @@ snapshots: property-information: 6.5.0 space-separated-tokens: 2.0.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} http-cache-semantics@4.1.1: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.1 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.5: + dependencies: + agent-base: 7.1.1 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + icss-replace-symbols@1.1.0: {} icss-utils@5.1.0(postcss@8.4.47): @@ -6107,6 +6486,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.6 @@ -6178,6 +6559,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@25.0.1: + dependencies: + cssstyle: 4.1.0 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.13 + parse5: 7.1.2 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + ws: 8.18.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@0.5.0: {} jsesc@2.5.2: {} @@ -6232,6 +6641,10 @@ snapshots: longest-streak@3.1.0: {} + loupe@3.1.1: + dependencies: + get-func-name: 2.0.2 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6759,6 +7172,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-function@5.0.1: {} minimatch@3.1.2: @@ -6797,6 +7216,8 @@ snapshots: number-is-nan@1.0.1: {} + nwsapi@2.2.13: {} + object-assign@4.1.1: {} object-inspect@1.13.2: {} @@ -6915,6 +7336,10 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + + pathval@2.0.0: {} + periscopic@3.1.0: dependencies: '@types/estree': 1.0.6 @@ -7174,6 +7599,8 @@ snapshots: property-information@6.5.0: {} + punycode@2.3.1: {} + queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -7416,6 +7843,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.22.4 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7441,6 +7870,12 @@ snapshots: es-errors: 1.3.0 is-regex: 1.1.4 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -7513,6 +7948,8 @@ snapshots: get-intrinsic: 1.2.4 object-inspect: 1.13.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-swizzle@0.2.2: @@ -7543,6 +7980,10 @@ snapshots: stable@0.1.8: {} + stackback@0.0.2: {} + + std-env@3.7.0: {} + stdin-discarder@0.2.2: {} string-hash@1.1.3: {} @@ -7658,6 +8099,8 @@ snapshots: picocolors: 1.1.0 stable: 0.1.8 + symbol-tree@3.2.4: {} + terser@5.34.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -7670,14 +8113,36 @@ snapshots: globalyzer: 0.1.0 globrex: 0.1.2 + tinybench@2.9.0: {} + tinyexec@0.3.0: {} + tinypool@1.0.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.48: {} + + tldts@6.1.48: + dependencies: + tldts-core: 6.1.48 + to-fast-properties@2.0.0: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.0.0: + dependencies: + tldts: 6.1.48 + + tr46@5.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -7827,6 +8292,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite-node@2.1.1(@types/node@22.7.2)(terser@5.34.0): + dependencies: + cac: 6.7.14 + debug: 4.3.7 + pathe: 1.1.2 + vite: 5.4.8(@types/node@22.7.2)(terser@5.34.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.8(@types/node@22.7.2)(terser@5.34.0): dependencies: esbuild: 0.21.5 @@ -7841,8 +8323,60 @@ snapshots: optionalDependencies: vite: 5.4.8(@types/node@22.7.2)(terser@5.34.0) + vitest@2.1.1(@types/node@22.7.2)(jsdom@25.0.1)(terser@5.34.0): + dependencies: + '@vitest/expect': 2.1.1 + '@vitest/mocker': 2.1.1(@vitest/spy@2.1.1)(vite@5.4.8(@types/node@22.7.2)(terser@5.34.0)) + '@vitest/pretty-format': 2.1.1 + '@vitest/runner': 2.1.1 + '@vitest/snapshot': 2.1.1 + '@vitest/spy': 2.1.1 + '@vitest/utils': 2.1.1 + chai: 5.1.1 + debug: 4.3.7 + magic-string: 0.30.11 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.9.0 + tinyexec: 0.3.0 + tinypool: 1.0.1 + tinyrainbow: 1.2.0 + vite: 5.4.8(@types/node@22.7.2)(terser@5.34.0) + vite-node: 2.1.1(@types/node@22.7.2)(terser@5.34.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.7.2 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.0.0: + dependencies: + tr46: 5.0.0 + webidl-conversions: 7.0.0 + which-boxed-primitive@1.0.2: dependencies: is-bigint: 1.0.4 @@ -7865,6 +8399,11 @@ snapshots: gopd: 1.0.1 has-tostringtag: 1.0.2 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@4.0.1: dependencies: string-width: 5.1.2 @@ -7883,6 +8422,12 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xxhash-wasm@1.0.2: {} y18n@5.0.8: {} diff --git a/src/ScrollMirror.ts b/src/ScrollMirror.ts new file mode 100644 index 0000000..cb35e52 --- /dev/null +++ b/src/ScrollMirror.ts @@ -0,0 +1,240 @@ +import type { Progress, Options } from "./support/defs.js"; +import { + getScrollProgress, + hasCSSOverflow, + hasOverflow, + nextTick, +} from "./support/helpers.js"; + +/** + * Mirrors the scroll position of multiple elements on a page + */ +export default class ScrollMirror { + /** Mirror the scroll positions of these elements */ + readonly elements: HTMLElement[]; + /** The default options */ + readonly defaults: Options = { + vertical: true, + horizontal: true, + }; + /** The parsed options */ + options: Options; + /** Is mirroring paused? */ + paused: boolean = false; + /** @internal */ + prefix: string = "[scroll-mirror]"; + + constructor( + elements: NodeListOf | Element[], + options: Partial = {} + ) { + this.elements = [...elements] + .filter(Boolean) + .map((el) => this.getScrollContainer(el)); + + // remove duplicates + this.elements = [...new Set(this.elements)]; + + this.options = { ...this.defaults, ...options }; + + this.validateElements(); + + this.elements.forEach((element) => this.addHandler(element)); + /** + * Initially, make sure that elements are mirrored to the + * documentElement's scroll position (if provided) + */ + if (this.elements.includes(document.documentElement)) { + this.mirrorScrollPositions( + getScrollProgress(document.documentElement), + document.documentElement + ); + } + } + + /** Pause mirroring */ + pause() { + this.paused = true; + } + + /** Resume mirroring */ + resume() { + this.paused = false; + } + + /** Destroy. Removes all event handlers */ + destroy() { + this.elements.forEach((element) => this.removeHandler(element)); + } + + /** Make sure the provided elements are valid @internal */ + validateElements(): void { + const elements = [...this.elements]; + + if (elements.length < 1) { + console.warn(`${this.prefix} No elements provided.`); + return; + } + + if (elements.length < 2) { + console.warn(`${this.prefix} Only one element provided.`, elements); + } + + if (elements.some((el) => !el)) { + console.error(`${this.prefix} some elements are not defined:`, elements); + } + + for (const element of elements) { + if (element instanceof HTMLElement && !hasOverflow(element)) { + console.warn(`${this.prefix} element doesn't have overflow:`, element); + } + if ( + element instanceof HTMLElement && + element.matches("body *") && + !hasCSSOverflow(element) + ) { + console.warn(`${this.prefix} no "overflow: auto;" or "overflow: scroll;" set on element:`, element); // prettier-ignore + } + } + } + + /** Add the scroll handler to the element @internal */ + addHandler(element: HTMLElement) { + /** Safeguard to prevent duplicate handlers on elements */ + this.removeHandler(element); + + const target = this.getEventTarget(element); + target.addEventListener("scroll", this.handleScroll); + } + + /** Remove the scroll handler from an element @internal */ + removeHandler(element: HTMLElement) { + const target = this.getEventTarget(element); + target.removeEventListener("scroll", this.handleScroll); + } + + /** + * Get the scroll container, based on element provided: + * - return the element if it's a child of + * - otherwise, return the documentElement + */ + getScrollContainer(el: unknown): HTMLElement { + if (el instanceof HTMLElement && el.matches("body *")) return el; + return document.documentElement; + } + + /** + * Get the event target for receiving scroll events + * - return the window if the element is either the html or body element + * - otherwise, return the element + */ + getEventTarget(element: HTMLElement): Window | HTMLElement { + return element.matches("body *") ? element : window; + } + + /** Handle a scroll event on an element @internal */ + handleScroll = async (event: Event) => { + if (this.paused) return; + + if (!event.currentTarget) return; + + const scrolledElement = this.getScrollContainer(event.currentTarget); + + await nextTick(); + + this.mirrorScrollPositions( + getScrollProgress(scrolledElement), + scrolledElement + ); + }; + + /** Mirror the scroll positions of all elements to a target @internal */ + mirrorScrollPositions( + progress: Progress, + ignore: HTMLElement | undefined = undefined + ) { + this.elements.forEach((element) => { + /* Ignore the currently scrolled element */ + if (ignore === element) return; + + /* Remove the scroll event listener */ + this.removeHandler(element); + + this.setScrollPosition(progress, element); + + /* Re-attach the scroll event listener */ + window.requestAnimationFrame(() => { + this.addHandler(element); + }); + }); + } + + /** Mirror the scroll position from one element to another @internal */ + setScrollPosition(progress: Progress, target: HTMLElement) { + const { vertical, horizontal } = this.options; + + /* Calculate the actual element scroll lengths */ + const availableScroll = { + x: target.scrollWidth - target.clientWidth, + y: target.scrollHeight - target.clientHeight, + }; + + /* Adjust the scroll position accordingly */ + if (vertical && !!availableScroll.y) { + target.scrollTo({ + top: availableScroll.y * progress.y, + behavior: "instant", + }); + } + if (horizontal && !!availableScroll.x) { + target.scrollTo({ + left: availableScroll.x * progress.x, + behavior: "instant", + }); + } + } + + get progress(): Progress { + const firstWithOverflow = this.elements.find((el) => hasOverflow(el)); + + return getScrollProgress(firstWithOverflow); + } + + /** + * Get or set the scroll progress of all mirrored elements + * + * The progress is an object of { x:number , y: number }, where both x and y are a number + * between 0-1 + * + * Examples: + * - `const progress = mirror.progress` — returns something like { x: 0.2, y:0.5 } + * - `mirror.progress = 0.5` — set the scroll position to 50% on both axes + * - `mirror.progress = { y: 0.5 }` — set the scroll position to 50% on the y axis + * - `mirror.progress = { x: 0.2, y: 0.5 }` — set the scroll position on both axes + */ + set progress(value: Partial | number) { + /** if the value is a number, set both axes to that value */ + if (typeof value === "number") { + value = { x: value, y: value }; + } + const progress = { ...this.progress, ...value }; + + if (!this.validateProgress(progress)) { + return; + } + + this.mirrorScrollPositions(progress); + } + + /** Validate the progress, log errors for invalid values */ + validateProgress(progress: Partial) { + let valid = true; + for (const [key, value] of Object.entries(progress)) { + if (typeof value !== "number" || value < 0 || value > 1) { + console.error(`progress.${key} must be a number between 0-1`); + valid = false; + } + } + return valid; + } +} diff --git a/src/index.ts b/src/index.ts index 5599687..0bb2982 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,252 +1,12 @@ -import type { Progress } from "./support/devs.js"; +import type { Options, Progress } from "./support/defs.js"; import { getScrollProgress, hasCSSOverflow, hasOverflow, nextTick, -} from "./support/utils.js"; +} from "./support/helpers.js"; +import ScrollMirror from './ScrollMirror.js'; -export type Options = { - /** Mirror the vertical scroll position */ - vertical: boolean; - /** Mirror the horizontal scroll position */ - horizontal: boolean; -}; - -/** - * Utility functions - */ +export type { Options, Progress }; export { getScrollProgress, hasOverflow, hasCSSOverflow, nextTick }; - -/** - * Mirrors the scroll position of multiple elements on a page - */ -export default class ScrollMirror { - /** Mirror the scroll positions of these elements */ - readonly elements: HTMLElement[]; - /** The default options */ - readonly defaults: Options = { - vertical: true, - horizontal: true, - }; - /** The parsed options */ - options: Options; - /** Is mirroring paused? */ - paused: boolean = false; - /** @internal */ - prefix: string = "[scroll-mirror]"; - - constructor( - elements: NodeListOf | Element[], - options: Partial = {}, - ) { - this.elements = [...elements] - .filter(Boolean) - .map((el) => this.getScrollContainer(el)); - - // remove duplicates - this.elements = [...new Set(this.elements)]; - - this.options = { ...this.defaults, ...options }; - - this.validateElements(); - - this.elements.forEach((element) => this.addHandler(element)); - /** - * Initially, make sure that elements are mirrored to the - * documentElement's scroll position (if provided) - */ - if (this.elements.includes(document.documentElement)) { - this.mirrorScrollPositions( - getScrollProgress(document.documentElement), - document.documentElement, - ); - } - } - - /** Pause mirroring */ - pause() { - this.paused = true; - } - - /** Resume mirroring */ - resume() { - this.paused = false; - } - - /** Destroy. Removes all event handlers */ - destroy() { - this.elements.forEach((element) => this.removeHandler(element)); - } - - /** Make sure the provided elements are valid @internal */ - validateElements(): void { - const elements = [...this.elements]; - - if (elements.length < 1) { - console.warn(`${this.prefix} No elements provided.`); - return; - } - - if (elements.length < 2) { - console.warn(`${this.prefix} Only one element provided.`, elements); - } - - if (elements.some((el) => !el)) { - console.error(`${this.prefix} some elements are not defined:`, elements); - } - - for (const element of elements) { - if (element instanceof HTMLElement && !hasOverflow(element)) { - console.warn(`${this.prefix} element doesn't have overflow:`, element); - } - if ( - element instanceof HTMLElement && - element.matches("body *") && - !hasCSSOverflow(element) - ) { - console.warn(`${this.prefix} no "overflow: auto;" or "overflow: scroll;" set on element:`, element); // prettier-ignore - } - } - } - - /** Add the scroll handler to the element @internal */ - addHandler(element: HTMLElement) { - /** Safeguard to prevent duplicate handlers on elements */ - this.removeHandler(element); - - const target = this.getEventTarget(element); - target.addEventListener("scroll", this.handleScroll); - } - - /** Remove the scroll handler from an element @internal */ - removeHandler(element: HTMLElement) { - const target = this.getEventTarget(element); - target.removeEventListener("scroll", this.handleScroll); - } - - /** - * Get the scroll container, based on element provided: - * - return the element if it's a child of - * - otherwise, return the documentElement - */ - getScrollContainer(el: unknown): HTMLElement { - if (el instanceof HTMLElement && el.matches("body *")) return el; - return document.documentElement; - } - - /** - * Get the event target for receiving scroll events - * - return the window if the element is either the html or body element - * - otherwise, return the element - */ - getEventTarget(element: HTMLElement): Window | HTMLElement { - return element.matches("body *") ? element : window; - } - - /** Handle a scroll event on an element @internal */ - handleScroll = async (event: Event) => { - if (this.paused) return; - - if (!event.currentTarget) return; - - const scrolledElement = this.getScrollContainer(event.currentTarget); - - await nextTick(); - - this.mirrorScrollPositions( - getScrollProgress(scrolledElement), - scrolledElement, - ); - }; - - /** Mirror the scroll positions of all elements to a target @internal */ - mirrorScrollPositions( - progress: Progress, - ignore: HTMLElement | undefined = undefined, - ) { - this.elements.forEach((element) => { - /* Ignore the currently scrolled element */ - if (ignore === element) return; - - /* Remove the scroll event listener */ - this.removeHandler(element); - - this.setScrollPosition(progress, element); - - /* Re-attach the scroll event listener */ - window.requestAnimationFrame(() => { - this.addHandler(element); - }); - }); - } - - /** Mirror the scroll position from one element to another @internal */ - setScrollPosition(progress: Progress, target: HTMLElement) { - const { vertical, horizontal } = this.options; - - /* Calculate the actual element scroll lengths */ - const availableScroll = { - x: target.scrollWidth - target.clientWidth, - y: target.scrollHeight - target.clientHeight, - }; - - /* Adjust the scroll position accordingly */ - if (vertical && !!availableScroll.y) { - target.scrollTo({ - top: availableScroll.y * progress.y, - behavior: "instant", - }); - } - if (horizontal && !!availableScroll.x) { - target.scrollTo({ - left: availableScroll.x * progress.x, - behavior: "instant", - }); - } - } - - get progress(): Progress { - const firstWithOverflow = this.elements.find((el) => hasOverflow(el)); - - return getScrollProgress(firstWithOverflow); - } - - /** - * Get or set the scroll progress of all mirrored elements - * - * The progress is an object of { x:number , y: number }, where both x and y are a number - * between 0-1 - * - * Examples: - * - `const progress = mirror.progress` — returns something like { x: 0.2, y:0.5 } - * - `mirror.progress = 0.5` — set the scroll position to 50% on both axes - * - `mirror.progress = { y: 0.5 }` — set the scroll position to 50% on the y axis - * - `mirror.progress = { x: 0.2, y: 0.5 }` — set the scroll position on both axes - */ - set progress(value: Partial | number) { - /** if the value is a number, set both axes to that value */ - if (typeof value === "number") { - value = { x: value, y: value }; - } - const progress = { ...this.progress, ...value }; - - if (!this.validateProgress(progress)) { - return; - } - - this.mirrorScrollPositions(progress); - } - - /** Validate the progress, log errors for invalid values */ - validateProgress(progress: Partial) { - let valid = true; - for (const [key, value] of Object.entries(progress)) { - if (typeof value !== "number" || value < 0 || value > 1) { - console.error(`progress.${key} must be a number between 0-1`); - valid = false; - } - } - return valid; - } -} +export default ScrollMirror; \ No newline at end of file diff --git a/src/support/defs.ts b/src/support/defs.ts new file mode 100644 index 0000000..94c128e --- /dev/null +++ b/src/support/defs.ts @@ -0,0 +1,11 @@ +export type Progress = { + x: number; + y: number; +}; + +export type Options = { + /** Mirror the vertical scroll position */ + vertical: boolean; + /** Mirror the horizontal scroll position */ + horizontal: boolean; +}; \ No newline at end of file diff --git a/src/support/devs.ts b/src/support/devs.ts deleted file mode 100644 index adde417..0000000 --- a/src/support/devs.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type Progress = { - x: number; - y: number; -}; diff --git a/src/support/utils.ts b/src/support/helpers.ts similarity index 96% rename from src/support/utils.ts rename to src/support/helpers.ts index 2c15955..2de3a2f 100644 --- a/src/support/utils.ts +++ b/src/support/helpers.ts @@ -1,4 +1,4 @@ -import type { Progress } from "./devs.js"; +import type { Progress } from "./defs.js"; /** Return a Promise that resolves after the next event loop. */ export const nextTick = (): Promise => { diff --git a/tests/unit/tests/exports.test.ts b/tests/unit/tests/exports.test.ts new file mode 100644 index 0000000..ec7efec --- /dev/null +++ b/tests/unit/tests/exports.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import * as ScrollMirrorModule from "../../../src/index.js"; +import ScrollMirror from "../../../src/index.js"; +import type { Options } from "../../../src/index.js"; +import * as ScrollMirrorTS from "../../../src/ScrollMirror.js"; + +describe("Exports", () => { + it("should have the correct exports for the es6 module", () => { + expect(Object.keys(ScrollMirrorModule)).toEqual([ + "getScrollProgress", + "hasOverflow", + "hasCSSOverflow", + "nextTick", + "default", + ]); + + const instance = new ScrollMirrorModule.default([ + document.createElement("div"), + ]); + expect(instance).toBeInstanceOf(ScrollMirror) + }); + + it("should only have a default export for the UMD bundle", () => { + expect(Object.keys(ScrollMirrorTS)).toEqual(["default"]); + }); +}); diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts new file mode 100644 index 0000000..5b61fb8 --- /dev/null +++ b/tests/unit/vitest.config.ts @@ -0,0 +1,17 @@ +/** + * Vitest config file + * @see https://vitest.dev/config/ + */ + +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(__filename); + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['tests/unit/tests/*.test.ts'], + setupFiles: [path.resolve(__dirname, './vitest.setup.ts')] + } +}); diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts new file mode 100644 index 0000000..2b416ba --- /dev/null +++ b/tests/unit/vitest.setup.ts @@ -0,0 +1,6 @@ +import { vi } from 'vitest'; + +// Stub browser functions for vitest +// console.log = vi.fn(); +// console.warn = vi.fn(); +// console.error = vi.fn(); From 07489c6fd5c73dc5c12abb70a36a32dfc594832a Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Fri, 18 Oct 2024 10:02:43 +0200 Subject: [PATCH 7/9] Add code docs --- src/ScrollMirror.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ScrollMirror.ts b/src/ScrollMirror.ts index cb35e52..67dc439 100644 --- a/src/ScrollMirror.ts +++ b/src/ScrollMirror.ts @@ -194,6 +194,9 @@ export default class ScrollMirror { } } + /** + * Get the scroll position from the first container that has overflow + */ get progress(): Progress { const firstWithOverflow = this.elements.find((el) => hasOverflow(el)); @@ -201,7 +204,7 @@ export default class ScrollMirror { } /** - * Get or set the scroll progress of all mirrored elements + * Set the scroll progress of all mirrored elements * * The progress is an object of { x:number , y: number }, where both x and y are a number * between 0-1 From 72c93499d06201882d5fee394cbfb9c027bd0f8e Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Fri, 18 Oct 2024 11:22:35 +0200 Subject: [PATCH 8/9] New option `{debug: false}` --- src/ScrollMirror.ts | 95 ++++++++++----------------------- src/support/defs.ts | 8 ++- src/support/functions.ts | 73 +++++++++++++++++++++++++ tests/unit/tests/logger.test.ts | 45 ++++++++++++++++ 4 files changed, 152 insertions(+), 69 deletions(-) create mode 100644 src/support/functions.ts create mode 100644 tests/unit/tests/logger.test.ts diff --git a/src/ScrollMirror.ts b/src/ScrollMirror.ts index 67dc439..d25262e 100644 --- a/src/ScrollMirror.ts +++ b/src/ScrollMirror.ts @@ -1,11 +1,17 @@ -import type { Progress, Options } from "./support/defs.js"; +import type { Progress, Options, Logger } from "./support/defs.js"; import { getScrollProgress, - hasCSSOverflow, hasOverflow, nextTick, } from "./support/helpers.js"; +import { + getScrollEventTarget, + getLogger, + validateElements, + validateProgress, +} from "./support/functions.js"; + /** * Mirrors the scroll position of multiple elements on a page */ @@ -16,13 +22,14 @@ export default class ScrollMirror { readonly defaults: Options = { vertical: true, horizontal: true, + debug: true }; /** The parsed options */ options: Options; /** Is mirroring paused? */ paused: boolean = false; - /** @internal */ - prefix: string = "[scroll-mirror]"; + /** a simple logger @internal */ + logger: Logger | undefined = undefined; constructor( elements: NodeListOf | Element[], @@ -32,14 +39,18 @@ export default class ScrollMirror { .filter(Boolean) .map((el) => this.getScrollContainer(el)); - // remove duplicates + /** remove duplicates */ this.elements = [...new Set(this.elements)]; this.options = { ...this.defaults, ...options }; - this.validateElements(); + if (this.options.debug) { + this.logger = getLogger("[scroll-mirror]"); + validateElements(this.elements, this.logger); + } + + this.elements.forEach((element) => this.addScrollHandler(element)); - this.elements.forEach((element) => this.addHandler(element)); /** * Initially, make sure that elements are mirrored to the * documentElement's scroll position (if provided) @@ -64,52 +75,21 @@ export default class ScrollMirror { /** Destroy. Removes all event handlers */ destroy() { - this.elements.forEach((element) => this.removeHandler(element)); - } - - /** Make sure the provided elements are valid @internal */ - validateElements(): void { - const elements = [...this.elements]; - - if (elements.length < 1) { - console.warn(`${this.prefix} No elements provided.`); - return; - } - - if (elements.length < 2) { - console.warn(`${this.prefix} Only one element provided.`, elements); - } - - if (elements.some((el) => !el)) { - console.error(`${this.prefix} some elements are not defined:`, elements); - } - - for (const element of elements) { - if (element instanceof HTMLElement && !hasOverflow(element)) { - console.warn(`${this.prefix} element doesn't have overflow:`, element); - } - if ( - element instanceof HTMLElement && - element.matches("body *") && - !hasCSSOverflow(element) - ) { - console.warn(`${this.prefix} no "overflow: auto;" or "overflow: scroll;" set on element:`, element); // prettier-ignore - } - } + this.elements.forEach((element) => this.removeScrollHandler(element)); } /** Add the scroll handler to the element @internal */ - addHandler(element: HTMLElement) { + addScrollHandler(element: HTMLElement) { /** Safeguard to prevent duplicate handlers on elements */ - this.removeHandler(element); + this.removeScrollHandler(element); - const target = this.getEventTarget(element); + const target = getScrollEventTarget(element); target.addEventListener("scroll", this.handleScroll); } /** Remove the scroll handler from an element @internal */ - removeHandler(element: HTMLElement) { - const target = this.getEventTarget(element); + removeScrollHandler(element: HTMLElement) { + const target = getScrollEventTarget(element); target.removeEventListener("scroll", this.handleScroll); } @@ -123,15 +103,6 @@ export default class ScrollMirror { return document.documentElement; } - /** - * Get the event target for receiving scroll events - * - return the window if the element is either the html or body element - * - otherwise, return the element - */ - getEventTarget(element: HTMLElement): Window | HTMLElement { - return element.matches("body *") ? element : window; - } - /** Handle a scroll event on an element @internal */ handleScroll = async (event: Event) => { if (this.paused) return; @@ -158,13 +129,13 @@ export default class ScrollMirror { if (ignore === element) return; /* Remove the scroll event listener */ - this.removeHandler(element); + this.removeScrollHandler(element); this.setScrollPosition(progress, element); /* Re-attach the scroll event listener */ window.requestAnimationFrame(() => { - this.addHandler(element); + this.addScrollHandler(element); }); }); } @@ -222,22 +193,10 @@ export default class ScrollMirror { } const progress = { ...this.progress, ...value }; - if (!this.validateProgress(progress)) { + if (!validateProgress(progress, this.logger)) { return; } this.mirrorScrollPositions(progress); } - - /** Validate the progress, log errors for invalid values */ - validateProgress(progress: Partial) { - let valid = true; - for (const [key, value] of Object.entries(progress)) { - if (typeof value !== "number" || value < 0 || value > 1) { - console.error(`progress.${key} must be a number between 0-1`); - valid = false; - } - } - return valid; - } } diff --git a/src/support/defs.ts b/src/support/defs.ts index 94c128e..62d3a02 100644 --- a/src/support/defs.ts +++ b/src/support/defs.ts @@ -1,3 +1,5 @@ +import type { getLogger } from "./functions.js"; + export type Progress = { x: number; y: number; @@ -8,4 +10,8 @@ export type Options = { vertical: boolean; /** Mirror the horizontal scroll position */ horizontal: boolean; -}; \ No newline at end of file + /** Enable debug messages */ + debug: boolean; +}; + +export type Logger = ReturnType; \ No newline at end of file diff --git a/src/support/functions.ts b/src/support/functions.ts new file mode 100644 index 0000000..4f3b2c2 --- /dev/null +++ b/src/support/functions.ts @@ -0,0 +1,73 @@ +import type { Logger, Progress } from "./defs.js"; +import { hasCSSOverflow, hasOverflow } from "./helpers.js"; + +/** + * Get the event target for receiving scroll events + * - return the window if the element is either the html or body element + * - otherwise, return the element + */ +export function getScrollEventTarget(element: HTMLElement): Window | HTMLElement { + return element.matches("body *") ? element : window; +} + +/** + * Get a minimal logger with a prefix + */ +export function getLogger(prefix: string) { + return { + log: (...args: any[]) => console.log(prefix, ...args), + warn: (...args: any[]) => console.warn(prefix, ...args), + error: (...args: any[]) => console.error(prefix, ...args), + }; +} + +/** + * Make sure the provided elements are valid + */ +export function validateElements( + elements: HTMLElement[], + logger?: Logger +): void { + if (elements.length < 1) { + logger?.warn("No elements provided."); + return; + } + + if (elements.length < 2) { + logger?.warn("Only one element provided.", elements); + } + + if (elements.some((el) => !el)) { + logger?.error("Some elements are not defined.", elements); + } + + for (const element of elements) { + if (element instanceof HTMLElement && !hasOverflow(element)) { + logger?.warn("Element doesn't have overflow:", element); + } + if ( + element instanceof HTMLElement && + element.matches("body *") && + !hasCSSOverflow(element) + ) { + logger?.warn('No "overflow: auto;" or "overflow: scroll;" set on element:', element); // prettier-ignore + } + } +} + +/** + * Validate the progress, log errors for invalid values + */ +export function validateProgress( + progress: Partial, + logger?: Logger +) { + let valid = true; + for (const [key, value] of Object.entries(progress)) { + if (typeof value !== "number" || value < 0 || value > 1) { + logger?.error(`progress.${key} must be a number between 0-1`); + valid = false; + } + } + return valid; +} \ No newline at end of file diff --git a/tests/unit/tests/logger.test.ts b/tests/unit/tests/logger.test.ts new file mode 100644 index 0000000..796fe71 --- /dev/null +++ b/tests/unit/tests/logger.test.ts @@ -0,0 +1,45 @@ +import { vi, describe, expect, it, beforeEach, afterEach } from "vitest"; + +import ScrollMirror from "../../../src/index.js"; + +describe("Logger", () => { + let warnSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn"); + errorSpy = vi.spyOn(console, "error"); + }); + + afterEach(() => { + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("should log if debug is true", () => { + const mirror = new ScrollMirror(document.querySelectorAll(":root"), { + debug: true, + }); + expect(warnSpy).toBeCalledWith( + expect.anything(), + "Only one element provided.", + expect.anything() + ); + + mirror.progress = { y: 2 }; + expect(errorSpy).toBeCalledWith( + expect.anything(), + "progress.y must be a number between 0-1" + ); + }); + + it("should not log if debug is false", () => { + const mirror = new ScrollMirror(document.querySelectorAll(":root"), { + debug: false, + }); + expect(warnSpy).not.toBeCalled(); + + mirror.progress = { y: 2 }; + expect(errorSpy).not.toBeCalled(); + }); +}); From db5a59b89de122c08715ad071c2211539c5d9b1a Mon Sep 17 00:00:00 2001 From: Rasso Hilber Date: Fri, 18 Oct 2024 11:27:12 +0200 Subject: [PATCH 9/9] Update README --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3487ae4..82b149f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ The type signature of the options object: type Options = { vertical: boolean; horizontal: boolean; + debug: boolean; } ``` @@ -104,6 +105,10 @@ Type: `boolean`, default: `true`. Should the vertical scroll position be mirrore Type: `boolean`, default: `true`. Should the horizontal scroll position be mirrored? +### `debug` + +Type: `boolean`, default: `true`. Should debug messages be printed to the console? + ## API To access ScrollMirror's API, you have to save a reference to the class during instaciation: @@ -114,14 +119,15 @@ const mirror = new ScrollMirror(document.querySelectorAll(".scroller")); ### `mirror.progress` -Returns the current scroll progress in the form of `{x: number, y: number}`, where both x and y are a +Get the current scroll progress in the form of `{ x: number, y: number }`, where both x and y are a number between 0-1 ### `mirror.progress = value` -Sets the progress and scrolls all mirrored elements. For example: +Set the progress and scrolls all mirrored elements. For example: ```js +// for both directions mirror.progress = { x: 0.2, y: 0.5 }; // or only set one direction mirror.progress = { y: 0.5 }; @@ -131,7 +137,13 @@ mirror.progress = 0.5; ### `mirror.getScrollProgress(element: HTMLElement)` -Return the current progress of an element. The element doesn't _need_ to be one of the mirrored elements +Get the current progress of an element. The element doesn't _need_ to be one of the mirrored elements + +```ts +const mirror = new ScrollMirror(document.querySelectorAll(".scroller")); +// ...sometime later: +console.log(mirror.getScrollProgress(document.querySelector(":root"))); +``` ## Motivation