/** * Tests for KTSticky component (PR #107: release delay, active/release classes, debounce) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as KTDomModule from '../../../helpers/dom'; import { KTSticky } from '../sticky'; import { waitFor } from '../../datatable/__tests__/setup'; describe('KTSticky', () => { let stickyEl: HTMLElement; let wrapper: HTMLElement; let scrollTop = 0; beforeEach(() => { document.body.innerHTML = ''; scrollTop = 0; vi.spyOn(KTDomModule.default, 'getScrollTop').mockImplementation( () => scrollTop, ); vi.spyOn(KTDomModule.default, 'getViewPort').mockReturnValue({ height: 800, width: 1024, }); wrapper = document.createElement('div'); wrapper.setAttribute('data-kt-sticky-wrapper', 'true'); wrapper.style.height = '60px'; stickyEl = document.createElement('div'); stickyEl.setAttribute('data-kt-sticky', 'true'); stickyEl.setAttribute('data-kt-sticky-name', 'test'); stickyEl.setAttribute('data-kt-sticky-target', 'body'); stickyEl.setAttribute('data-kt-sticky-offset', '100'); stickyEl.setAttribute('data-kt-sticky-zindex', '10'); // required for _enable() to set position: fixed stickyEl.style.height = '60px'; wrapper.appendChild(stickyEl); document.body.appendChild(wrapper); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); document.body.innerHTML = ''; }); describe('PR #107: data-kt-sticky-release-delay', () => { it('reads releaseDelay from data-kt-sticky-release-delay attribute', () => { stickyEl.setAttribute('data-kt-sticky-release-delay', '300'); const instance = new KTSticky(stickyEl); expect(instance.getOption('releaseDelay')).toBe(300); instance.dispose(); }); it('delays clearing inline styles when sticky is released (smooth exit)', async () => { vi.useFakeTimers(); stickyEl.setAttribute('data-kt-sticky-release-delay', '250'); const instance = new KTSticky(stickyEl); // Scroll past offset -> sticky becomes active (sync, no debounce when not active) scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); vi.advanceTimersByTime(0); expect(stickyEl.classList.contains('active')).toBe(true); expect(stickyEl.style.position).toBe('fixed'); // Scroll back up -> release (when active, debounce 200ms then _process) scrollTop = 50; window.dispatchEvent(new Event('scroll', { bubbles: true })); vi.advanceTimersByTime(250); // debounce 200ms + a bit expect(stickyEl.classList.contains('release')).toBe(true); expect(stickyEl.style.position).toBe('fixed'); // not cleared yet (delay 250ms) vi.advanceTimersByTime(250); // release delay expect(stickyEl.style.position).toBe(''); expect(stickyEl.style.zIndex).toBe(''); vi.useRealTimers(); instance.dispose(); }); it('with no releaseDelay, inline styles are cleared immediately on release', async () => { stickyEl.removeAttribute('data-kt-sticky-release-delay'); const instance = new KTSticky(stickyEl); scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(50); expect(stickyEl.style.position).toBe('fixed'); scrollTop = 50; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(250); // debounce 200ms expect(stickyEl.style.position).toBe(''); instance.dispose(); }); }); describe('PR #107: data-kt-sticky-active-class and data-kt-sticky-release-class', () => { it('applies activeClass when sticky is active and releaseClass when released', async () => { stickyEl.setAttribute('data-kt-sticky-active-class', 'sticky-on'); stickyEl.setAttribute('data-kt-sticky-release-class', 'sticky-off'); const instance = new KTSticky(stickyEl); scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(50); expect(stickyEl.classList.contains('sticky-on')).toBe(true); expect(stickyEl.classList.contains('sticky-off')).toBe(false); scrollTop = 50; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(250); expect(stickyEl.classList.contains('sticky-on')).toBe(false); expect(stickyEl.classList.contains('sticky-off')).toBe(true); instance.dispose(); }); it('falls back to data-kt-sticky-class when activeClass is not set', async () => { stickyEl.setAttribute('data-kt-sticky-class', 'legacy-sticky'); const instance = new KTSticky(stickyEl); scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(50); expect(stickyEl.classList.contains('legacy-sticky')).toBe(true); instance.dispose(); }); }); describe('debounced scroll (no flicker on rapid scroll)', () => { it('when active, scroll handler debounces so _process runs after delay', async () => { vi.useFakeTimers(); const instance = new KTSticky(stickyEl); scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); vi.advanceTimersByTime(0); expect(stickyEl.classList.contains('active')).toBe(true); // Rapid scroll back: multiple scroll events, only one _process after debounce scrollTop = 50; window.dispatchEvent(new Event('scroll', { bubbles: true })); window.dispatchEvent(new Event('scroll', { bubbles: true })); window.dispatchEvent(new Event('scroll', { bubbles: true })); expect(stickyEl.classList.contains('release')).toBe(false); // not yet, debounce pending vi.advanceTimersByTime(250); expect(stickyEl.classList.contains('release')).toBe(true); vi.useRealTimers(); instance.dispose(); }); }); describe('change event', () => { it('fires change event when sticky becomes active and when released', async () => { const instance = new KTSticky(stickyEl); const changes: { active: boolean }[] = []; instance.on('change', (payload: { active: boolean }) => changes.push(payload), ); scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(50); expect(changes).toContainEqual({ active: true }); scrollTop = 50; window.dispatchEvent(new Event('scroll', { bubbles: true })); await waitFor(250); expect(changes).toContainEqual({ active: false }); instance.dispose(); }); }); describe('dispose', () => { it('clears release-delay timeout so _resetStyles does not run after dispose', async () => { vi.useFakeTimers(); stickyEl.setAttribute('data-kt-sticky-release-delay', '500'); const instance = new KTSticky(stickyEl); scrollTop = 150; window.dispatchEvent(new Event('scroll', { bubbles: true })); vi.advanceTimersByTime(0); scrollTop = 50; window.dispatchEvent(new Event('scroll', { bubbles: true })); vi.advanceTimersByTime(250); expect(stickyEl.style.position).toBe('fixed'); instance.dispose(); vi.advanceTimersByTime(600); // If timeout weren't cleared, _resetStyles would have run; element is disposed so we just ensure no throw vi.useRealTimers(); }); }); describe('getInstance / getOrCreateInstance', () => { it('getInstance returns null for element without data-kt-sticky', () => { stickyEl.removeAttribute('data-kt-sticky'); expect(KTSticky.getInstance(stickyEl)).toBeNull(); }); it('getOrCreateInstance creates instance and returns it', () => { const instance = KTSticky.getOrCreateInstance(stickyEl); expect(instance).toBeInstanceOf(KTSticky); expect(KTSticky.getInstance(stickyEl)).toBe(instance); instance.dispose(); }); }); });