// @vitest-environment happy-dom import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ScrollSpy } from './scrollspy'; declare global { const M: { ScrollSpy: typeof ScrollSpy; }; } const globalM = { ScrollSpy }; globalThis.M = globalM; if (typeof window !== 'undefined') { (window as unknown as { M: typeof globalM }).M = globalM; (window as unknown as { ScrollSpy: typeof ScrollSpy }).ScrollSpy = ScrollSpy; } describe('Scrollspy with happy-dom', () => { const DELAY_IN_MS = 40; const fixture1 = `
introduction
initialization
options
`; const fixture2 = `
introduction
initialization
options
`; const defaultOptions = { animationDuration: 1 }; let scrollspyInstances: ScrollSpy[] = []; function mockLayout() { // 1. Mock window.scrollTo to update scrollY and trigger scroll events vi.spyOn(window, 'scrollTo').mockImplementation( (xOrOptions: number | ScrollToOptions, y?: number) => { let targetY = 0; if (typeof xOrOptions === 'object') { targetY = xOrOptions.top ?? window.scrollY; } else if (typeof y === 'number') { targetY = y; } Object.defineProperty(window, 'scrollY', { value: targetY, writable: true, configurable: true }); window.dispatchEvent(new Event('scroll')); } ); // 2. Mock element dimensions & positions based on DOM order and inline styles const allBlocks = document.querySelectorAll('.section.scrollspy, .noScrollSpy'); let cumulativeTop = 0; allBlocks.forEach((el) => { const htmlEl = el as HTMLElement; const styleHeight = htmlEl.style.height; let pixelHeight = window.innerHeight; // Default to 1vh = innerHeight if (styleHeight.includes('vh')) { pixelHeight = (parseFloat(styleHeight) / 100) * window.innerHeight; } const top = cumulativeTop; const height = pixelHeight; vi.spyOn(htmlEl, 'getBoundingClientRect').mockImplementation(() => { const currentTop = top - window.scrollY; return { top: currentTop, bottom: currentTop + height, left: 0, right: 300, width: 300, height: height, x: 0, y: currentTop, toJSON: () => {} }; }); cumulativeTop += height; }); // Mock body height for scrollTo(document.body.scrollHeight) Object.defineProperty(document.body, 'scrollHeight', { value: cumulativeTop, configurable: true }); } function loadHtml(html: string) { document.body.innerHTML = html; mockLayout(); } function unloadFixtures() { document.body.innerHTML = ''; vi.restoreAllMocks(); } function isItemActive(value: string, activeClassName = 'active') { const element = document.querySelector(`a[href="#${value}"]`); return element ? Array.from(element.classList).includes(activeClassName) : false; } function expectOnlyThisElementIsActive(value: string, activeClassName?: string) { ['introduction', 'initialization', 'options'] .filter((el) => el !== value) .forEach((el) => { expect(isItemActive(el, activeClassName), `expecting ${el} not to be active`).toBe(false); }); expect(isItemActive(value, activeClassName), `expecting ${value} to be active`).toBe(true); } function expectNoActiveElements(activeClassName?: string) { ['introduction', 'initialization', 'options'].forEach((el) => { expect(isItemActive(el, activeClassName), `expecting ${el} not to be active`).toBe(false); }); } function resetScrollspy(options = {}) { scrollspyInstances.forEach((value) => value.destroy && value.destroy()); const elements = document.querySelectorAll('.scrollspy'); scrollspyInstances = M.ScrollSpy.init(elements, options); } function clickLink(value: string) { const targetEl = document.getElementById(value); if (targetEl) { window.scrollTo(0, getDistanceFromTop(targetEl)); } const element = document.querySelector(`a[href="#${value}"]`); if (element) { (element as HTMLElement).click(); } } function getDistanceFromTop(element: HTMLElement) { const rect = element.getBoundingClientRect(); const scrollTop = window.scrollY || window.pageYOffset; return rect.top + scrollTop; } function scrollTo(targetPosition: number) { window.scrollTo(0, targetPosition); } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('Scrollspy keepTopElementActive', () => { beforeEach(() => { loadHtml(fixture2); window.scrollTo(0, 0); const elements = document.querySelectorAll('.scrollspy'); scrollspyInstances = M.ScrollSpy.init(elements, defaultOptions); }); afterEach(() => { scrollspyInstances.forEach((value) => value.destroy && value.destroy()); unloadFixtures(); }); it('Test first element is active on true keepTopElementActive even if the elements are much lower down on the page', () => { resetScrollspy({ keepTopElementActive: true }); expectOnlyThisElementIsActive('introduction'); }); it('Test default keepTopElementActive value if false', () => { expectNoActiveElements(); }); it('Test no active elements on false keepTopElementActive if the elements are much lower down on the page', () => { resetScrollspy({ keepTopElementActive: false }); expectNoActiveElements(); }); it('Test scroll to the bottom and to the top of the page should keep last and then first element active', async () => { resetScrollspy({ ...defaultOptions, keepTopElementActive: true }); scrollTo(document.body.scrollHeight); await sleep(DELAY_IN_MS); expectOnlyThisElementIsActive('options'); scrollTo(0); await sleep(DELAY_IN_MS); expectOnlyThisElementIsActive('introduction'); }); it('Test scroll to the noScrollSpy sections should keep nearest top element active on true keepTopElementActive', async () => { resetScrollspy({ ...defaultOptions, keepTopElementActive: true }); const [, noScrollSpy2, noScrollSpy3, noScrollSpy4] = document.querySelectorAll('.noScrollSpy'); scrollTo(getDistanceFromTop(noScrollSpy2 as HTMLElement)); await sleep(DELAY_IN_MS); expectOnlyThisElementIsActive('introduction'); scrollTo(getDistanceFromTop(noScrollSpy3 as HTMLElement)); await sleep(DELAY_IN_MS); expectOnlyThisElementIsActive('initialization'); scrollTo(getDistanceFromTop(noScrollSpy4 as HTMLElement)); await sleep(DELAY_IN_MS); expectOnlyThisElementIsActive('options'); }); it('Test on false keepTopElementActive scroll to the noScrollSpy should not make active elements', async () => { resetScrollspy({ ...defaultOptions, keepTopElementActive: false }); const [, noScrollSpy2, noScrollSpy3, noScrollSpy4] = document.querySelectorAll('.noScrollSpy'); scrollTo(getDistanceFromTop(noScrollSpy2 as HTMLElement)); await sleep(DELAY_IN_MS); expectNoActiveElements(); scrollTo(getDistanceFromTop(noScrollSpy3 as HTMLElement)); await sleep(DELAY_IN_MS); expectNoActiveElements(); scrollTo(getDistanceFromTop(noScrollSpy4 as HTMLElement)); await sleep(DELAY_IN_MS); expectNoActiveElements(); }); }); describe('Scrollspy basic test cases', () => { beforeEach(() => { loadHtml(fixture1); window.scrollTo(0, 0); const elements = document.querySelectorAll('.scrollspy'); scrollspyInstances = M.ScrollSpy.init(elements, defaultOptions); }); afterEach(() => { scrollspyInstances.forEach((value) => value.destroy && value.destroy()); unloadFixtures(); }); it('Test scrollspy native smooth behavior', async () => { resetScrollspy({ ...defaultOptions, animationDuration: null }); const viewportHeightPx = window.innerHeight; clickLink('options'); await sleep(DELAY_IN_MS); const scrollTop = window.scrollY; expect(scrollTop).toBe(viewportHeightPx * 2); }); it('Test scrollspy smooth behavior positive case', async () => { const viewportHeightPx = window.innerHeight; clickLink('options'); await sleep(DELAY_IN_MS); const scrollTop = window.scrollY; expect(scrollTop).toBe(viewportHeightPx * 2); }); it('Test scrollspy smooth behavior negative case', async () => { resetScrollspy({ ...defaultOptions, animationDuration: 100 }); const viewportHeightPx = window.innerHeight; // Override scroll position simulation for intermediate animation frame state const element = document.querySelector('a[href="#options"]'); if (element) { (element as HTMLElement).click(); } // Set intermediate scroll position during 100ms animation window.scrollTo(0, viewportHeightPx * 0.5); await sleep(5); const scrollTop = window.scrollY; expect( scrollTop, "Scroll animation shouldn't reach the element in the given time" ).toBeLessThan(viewportHeightPx * 2); await sleep(120); }); }); });