/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import KTData from '../../helpers/data'; import KTDom from '../../helpers/dom'; import KTUtils from '../../helpers/utils'; import KTComponent from '../component'; import { KTStickyInterface, KTStickyConfigInterface } from './types'; declare global { interface Window { KT_STICKY_INITIALIZED: boolean; KTSticky: typeof KTSticky; } } export class KTSticky extends KTComponent implements KTStickyInterface { protected override _name: string = 'sticky'; protected override _defaultConfig: KTStickyConfigInterface = { target: 'body', name: '', class: '', top: '', middle: false, bottom: '', start: '', center: false, end: '', width: '', zindex: '', offset: 0, reverse: false, release: '', activate: '', releaseDelay: 0, activeClass: '', releaseClass: '', }; protected override _config: KTStickyConfigInterface = this._defaultConfig; protected _targetElement: HTMLElement | Document | null = null; protected _attributeRoot: string; protected _isScrolling: boolean; protected _timeoutState: ReturnType | null = null; protected _scrollTimeout: ReturnType | null = null; protected _eventTriggerState: boolean; protected _lastScrollTop: number; protected _releaseElement: HTMLElement; protected _activateElement: HTMLElement; protected _wrapperElement: HTMLElement; private _resizeHandler: (() => void) | null = null; private _scrollHandler: (() => void) | null = null; constructor( element: HTMLElement, config: KTStickyConfigInterface | null = null, ) { super(); // Check if element already has an instance and is still connected if (this._shouldSkipInit(element)) { return; } this._init(element); this._buildConfig(config); this._releaseElement = KTDom.getElement( this._getOption('release') as string, ); this._activateElement = KTDom.getElement( this._getOption('activate') as string, ); this._wrapperElement = this._element.closest('[data-kt-sticky-wrapper]'); this._attributeRoot = `data-kt-sticky-${this._getOption('name')}`; this._isScrolling = false; this._timeoutState = null; this._scrollTimeout = null; this._eventTriggerState = true; this._lastScrollTop = 0; const targetElement = this._getTarget() === 'body' ? document : KTDom.getElement(this._getTarget()); if (!targetElement) return; this._targetElement = targetElement; this._handlers(); this._process(); this._update(); } private _getTarget(): string { return ( (this._element.getAttribute('data-kt-sticky-target') as string) || (this._getOption('target') as string) ); } protected _handlers(): void { // Store resize handler reference for cleanup this._resizeHandler = () => { let timer; KTUtils.throttle( timer, () => { this._update(); }, 200, ); }; window.addEventListener('resize', this._resizeHandler); // Store scroll handler reference for cleanup this._scrollHandler = () => { this._isScrolling = true; if (this._isActive() === true) { this._debounceScroll(() => { this._isScrolling = false; this._process(); }, 200); } else { this._isScrolling = false; this._process(); } }; if (this._targetElement) { if (this._targetElement === document) { window.addEventListener('scroll', this._scrollHandler, { passive: true, }); } else { (this._targetElement as HTMLElement).addEventListener( 'scroll', this._scrollHandler, { passive: true }, ); } } } protected _debounceScroll(callback: () => void, delay: number = 200): void { if (this._scrollTimeout) { clearTimeout(this._scrollTimeout); } this._scrollTimeout = setTimeout(() => { callback(); }, delay); } protected _process(): void { const reverse = this._getOption('reverse'); const offset = this._getOffset(); if (offset <= 0) { this._disable(); return; } const st = this._getTarget() === 'body' ? KTDom.getScrollTop() : (this._targetElement as HTMLElement).scrollTop; const release = this._releaseElement && KTDom.isPartiallyInViewport(this._releaseElement); // Release on reverse scroll mode if (reverse === true) { // Forward scroll mode if (st > offset && !release) { if (document.body.hasAttribute(this._attributeRoot) === false) { if (this._enable() === false) { return; } document.body.setAttribute(this._attributeRoot, 'on'); } if (this._eventTriggerState === true) { const payload = { active: true }; this._fireEvent('change', payload); this._dispatchEvent('change', payload); this._eventTriggerState = false; } // Back scroll mode } else { if (document.body.hasAttribute(this._attributeRoot) === true) { this._disable(); if (release) { this._element.classList.add('release'); } document.body.removeAttribute(this._attributeRoot); } if (this._eventTriggerState === false) { const payload = { active: false }; this._fireEvent('change', payload); this._dispatchEvent('change', payload); this._eventTriggerState = true; } } this._lastScrollTop = st; // Classic scroll mode } else { // Forward scroll mode if (st > offset && !release) { if (document.body.hasAttribute(this._attributeRoot) === false) { if (this._enable() === false) { return; } document.body.setAttribute(this._attributeRoot, 'on'); } if (this._eventTriggerState === true) { const payload = { active: true }; this._fireEvent('change', payload); this._dispatchEvent('change', payload); this._eventTriggerState = false; } // Back scroll mode } else { // back scroll mode if (document.body.hasAttribute(this._attributeRoot) === true) { this._disable(); if (release) { this._element.classList.add('release'); } document.body.removeAttribute(this._attributeRoot); } if (this._eventTriggerState === false) { const payload = { active: false }; this._fireEvent('change', payload); this._dispatchEvent('change', payload); this._eventTriggerState = true; } } } } protected _getOffset(): number { let offset = parseInt(this._getOption('offset') as string); const activateElement = KTDom.getElement( this._getOption('activate') as string, ); if (activateElement) { offset = Math.abs(offset - activateElement.offsetTop); } return offset; } protected _enable(): boolean { if (!this._element) return false; let width = this._getOption('width') as string; const top = this._getOption('top') as string; const middle = this._getOption('middle') as boolean; const bottom = this._getOption('bottom') as string; const start = this._getOption('start') as string; const center = this._getOption('center') as boolean; const end = this._getOption('end') as string; const height = this._calculateHeight(); const zindex = this._getOption('zindex') as string; if (height + parseInt(top) > KTDom.getViewPort().height) { return false; } if (width) { const targetElement = document.querySelector(width) as HTMLElement; if (targetElement) { width = KTDom.getCssProp(targetElement, 'width'); } else if (width == 'auto') { width = KTDom.getCssProp(this._element, 'width'); } this._element.style.width = `${Math.round(parseFloat(width))}px`; } if (middle === true) { this._element.style.insetBlockStart = `50%`; } else { if (top) { if (top === 'auto') { this._element.style.insetBlockStart = `0px`; } else { this._element.style.insetBlockStart = `${top}px`; } } else { if (bottom) { if (bottom === 'auto') { this._element.style.insetBlockEnd = `0px`; } else { this._element.style.insetBlockEnd = `${bottom}px`; } } } } if (center === true) { this._element.style.insetInlineStart = `50%`; } else { if (start) { if (start === 'auto') { const offsetLeft = KTDom.offset(this._element).left; if (offsetLeft >= 0) { this._element.style.insetInlineStart = `${offsetLeft}px`; } } else { this._element.style.insetInlineStart = `${start}px`; } } else { if (end) { if (end === 'auto') { const offsetRight = KTDom.offset(this._element).right; if (offsetRight >= 0) { this._element.style.insetInlineEnd = `${offsetRight}px`; } } else { this._element.style.insetInlineEnd = `${end}px`; } } } } if (zindex) { this._element.style.zIndex = zindex; this._element.style.position = 'fixed'; } const activeClassList = this._getOption('activeClass') as string; if (activeClassList) { KTDom.addClass(this._element, activeClassList); } else { const classList = this._getOption('class') as string; if (classList) { KTDom.addClass(this._element, classList); } } const releaseClassList = this._getOption('releaseClass') as string; if (releaseClassList) { KTDom.removeClass(this._element, releaseClassList); } if (this._wrapperElement) { this._wrapperElement.style.height = `${height}px`; } this._element.classList.remove('release'); this._element.classList.add('active'); return true; } protected _disable(): void { if (!this._element) return; if (this._wrapperElement) { this._wrapperElement.style.height = ''; } this._element.classList.remove('active'); this._element.classList.add('release'); const activeClassList = this._getOption('activeClass') as string; if (activeClassList) { KTDom.removeClass(this._element, activeClassList); } else { const classList = this._getOption('class') as string; if (classList) { KTDom.removeClass(this._element, classList); } } const releaseClassList = this._getOption('releaseClass') as string; if (releaseClassList) { KTDom.addClass(this._element, releaseClassList); } if (this._eventTriggerState === false) { const releaseDelay = this._getOption('releaseDelay') as number; if (releaseDelay && this._timeoutState === null) { this._timeoutState = setTimeout(() => { if (!this._element) { return; } if (this._isRelease() === true) { this._resetStyles(); } this._timeoutState = null; }, releaseDelay); } else { this._resetStyles(); } } else { this._timeoutState = null; } } protected _resetStyles(): void { this._element.style.top = ''; this._element.style.bottom = ''; this._element.style.insetInlineStart = ''; this._element.style.insetInlineEnd = ''; this._element.style.insetBlockStart = ''; this._element.style.insetBlockEnd = ''; this._element.style.width = ''; this._element.style.left = ''; this._element.style.right = ''; this._element.style.zIndex = ''; this._element.style.position = ''; } protected _update(): void { this._timeoutState = null; this._eventTriggerState = true; if (this._isActive()) { this._disable(); this._enable(); } else { this._disable(); } } protected _calculateHeight(): number { if (!this._element) return 0; let height = parseFloat(KTDom.getCssProp(this._element, 'height')); height += parseFloat(KTDom.getCssProp(this._element, 'margin-top')); height += parseFloat(KTDom.getCssProp(this._element, 'margin-bottom')); if (KTDom.getCssProp(this._element, 'border-top')) { height = height + parseFloat(KTDom.getCssProp(this._element, 'border-top')); } if (KTDom.getCssProp(this._element, 'border-bottom')) { height = height + parseFloat(KTDom.getCssProp(this._element, 'border-bottom')); } return height; } protected _isActive(): boolean { return this._element.classList.contains('active'); } protected _isRelease(): boolean { return this._element.classList.contains('release'); } public update(): void { this._update(); } public isActive(): boolean { return this._isActive(); } public isRelease(): boolean { return this._isRelease(); } public override dispose(): void { // Remove resize event listener if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } // Remove scroll event listener if (this._scrollHandler) { if (this._targetElement === document) { window.removeEventListener('scroll', this._scrollHandler); } else if (this._targetElement) { (this._targetElement as HTMLElement).removeEventListener( 'scroll', this._scrollHandler, ); } this._scrollHandler = null; } // Clean up state this._disable(); if ( this._attributeRoot && document.body.hasAttribute(this._attributeRoot) ) { document.body.removeAttribute(this._attributeRoot); } // Call parent dispose to clean up data attributes and KTData super.dispose(); } public static getInstance(element: HTMLElement): KTSticky { if (!element) return null; if (KTData.has(element, 'sticky')) { return KTData.get(element, 'sticky') as KTSticky; } if (element.getAttribute('data-kt-sticky')) { return new KTSticky(element); } return null; } public static getOrCreateInstance( element: HTMLElement, config?: KTStickyConfigInterface, ): KTSticky { return this.getInstance(element) || new KTSticky(element, config); } public static createInstances(): void { const elements = document.querySelectorAll('[data-kt-sticky]'); elements.forEach((element) => { new KTSticky(element as HTMLElement); }); } public static init(): void { KTSticky.createInstances(); } } if (typeof window !== 'undefined') { window.KTSticky = KTSticky; }