PageSourceSearch

https://www.freelo.io/js/affix.js?v=tm8acb

js freelo.io collected 2026-10-01 12:38:26 UTC 8,393 bytes, 252 lines download raw bytes

1/**
2 * Affix Component - Bootstrap Removal Phase 14
3 *
4 * Vanilla JS replacement for Bootstrap's affix plugin.
5 * Watches scroll position and toggles affix classes.
6 *
7 * FEATURES:
8 * - Automatically creates placeholder to prevent content jump when affixing
9 * - Handles viewport resize and orientation changes
10 * - Respects elements with position: sticky (doesn't override with affix-top)
11 *
12 * USAGE:
13 * <div data-spy="affix" data-offset-top="10">
14 *     ...
15 * </div>
16 *
17 * Classes applied:
18 * - .affix-top: When scrolled above the offset (at top of page)
19 * - .affix: When scrolled past the offset (sticky state)
20 * - .affix-bottom: When scrolled to bottom (optional, requires data-offset-bottom)
21 */
22
23class Affix {
24    constructor(element) {
25        this.element = element;
26        this.offsetTop = parseInt(element.dataset.offsetTop, 10) || 0;
27        this.offsetBottom = parseInt(element.dataset.offsetBottom, 10) || null;
28        this.state = null;
29        this.placeholder = null;
30        this.originalRect = null;
31
32        // Get the target element for offset calculation (if specified)
33        const targetSelector = element.dataset.target;
34        this.target = targetSelector ? document.querySelector(targetSelector) : window;
35
36        this.init();
37    }
38
39    init() {
40        // Set initial state
41        this.checkPosition();
42
43        // Throttled scroll handler
44        let ticking = false;
45        const scrollHandler = () => {
46            if (!ticking) {
47                window.requestAnimationFrame(() => {
48                    this.checkPosition();
49                    ticking = false;
50                });
51                ticking = true;
52            }
53        };
54
55        // Resize handler - needs to update placeholder dimensions
56        let resizeTicking = false;
57        const resizeHandler = () => {
58            if (!resizeTicking) {
59                window.requestAnimationFrame(() => {
60                    this.updatePlaceholderDimensions();
61                    this.checkPosition();
62                    resizeTicking = false;
63                });
64                resizeTicking = true;
65            }
66        };
67
68        window.addEventListener('scroll', scrollHandler, { passive: true });
69        window.addEventListener('resize', resizeHandler, { passive: true });
70        window.addEventListener('orientationchange', resizeHandler, { passive: true });
71    }
72
73    /**
74     * Create placeholder element to prevent content jump
75     */
76    createPlaceholder() {
77        if (this.placeholder) return;
78
79        // Store current rect before any changes
80        const rect = this.element.getBoundingClientRect();
81        const computedStyle = window.getComputedStyle(this.element);
82
83        // Get all relevant dimensions
84        const height = this.element.offsetHeight;
85        const width = this.element.offsetWidth;
86        const marginTop = parseFloat(computedStyle.marginTop) || 0;
87        const marginBottom = parseFloat(computedStyle.marginBottom) || 0;
88
89        // Store for later use
90        this.originalRect = { height, width, marginTop, marginBottom };
91
92        // Create placeholder
93        this.placeholder = document.createElement('div');
94        this.placeholder.className = 'affix-placeholder';
95        this.placeholder.style.cssText = `
96            display: block;
97            width: 100%;
98            height: ${height}px;
99            margin-top: ${marginTop}px;
100            margin-bottom: ${marginBottom}px;
101            pointer-events: none;
102            visibility: hidden;
103        `;
104
105        // Insert placeholder right before the element
106        this.element.parentNode.insertBefore(this.placeholder, this.element);
107    }
108
109    /**
110     * Remove placeholder element
111     */
112    removePlaceholder() {
113        if (this.placeholder && this.placeholder.parentNode) {
114            this.placeholder.parentNode.removeChild(this.placeholder);
115            this.placeholder = null;
116        }
117        this.originalRect = null;
118    }
119
120    /**
121     * Update placeholder dimensions on resize
122     */
123    updatePlaceholderDimensions() {
124        if (!this.placeholder || this.state !== 'affix') return;
125
126        // Temporarily switch to measure
127        this.element.classList.add('affix-top');
128        this.element.classList.remove('affix');
129        this.placeholder.style.display = 'none';
130
131        // Measure
132        const height = this.element.offsetHeight;
133        const width = this.element.offsetWidth;
134
135        // Update placeholder
136        this.placeholder.style.height = `${height}px`;
137        this.placeholder.style.display = 'block';
138
139        // Restore
140        this.element.classList.remove('affix-top');
141        this.element.classList.add('affix');
142
143        this.originalRect = { ...this.originalRect, height, width };
144    }
145
146    getState(scrollTop, scrollHeight, offsetTop, offsetBottom) {
147        const documentHeight = document.documentElement.scrollHeight;
148        const windowHeight = window.innerHeight;
149
150        // Check if we should be at bottom
151        if (offsetBottom !== null) {
152            if (scrollTop + windowHeight >= documentHeight - offsetBottom) {
153                return 'bottom';
154            }
155        }
156
157        // Check if we should be affixed
158        if (scrollTop >= offsetTop) {
159            return 'affix';
160        }
161
162        // Default: at top
163        return 'top';
164    }
165
166    checkPosition() {
167        const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
168        const scrollHeight = document.documentElement.scrollHeight;
169
170        const state = this.getState(scrollTop, scrollHeight, this.offsetTop, this.offsetBottom);
171
172        if (state !== this.state) {
173            this.setState(state);
174        }
175    }
176
177    setState(state) {
178        const prevState = this.state;
179        this.state = state;
180
181        switch (state) {
182            case 'top':
183                // Remove affix first, then placeholder
184                this.element.classList.remove('affix', 'affix-bottom');
185                this.element.classList.add('affix-top');
186                this.removePlaceholder();
187                break;
188
189            case 'affix':
190                // Create placeholder BEFORE changing to fixed
191                if (prevState !== 'affix') {
192                    this.createPlaceholder();
193                }
194                this.element.classList.remove('affix-top', 'affix-bottom');
195                this.element.classList.add('affix');
196                break;
197
198            case 'bottom':
199                this.element.classList.remove('affix-top', 'affix');
200                this.element.classList.add('affix-bottom');
201                this.removePlaceholder();
202                break;
203        }
204
205        // Dispatch custom event
206        this.element.dispatchEvent(new CustomEvent('affix:change', {
207            bubbles: true,
208            detail: { state, prevState, affix: this }
209        }));
210
211        // Dispatch state-specific events (Bootstrap compatibility)
212        if (state === 'affix') {
213            this.element.dispatchEvent(new CustomEvent('affix.bs.affix', { bubbles: true }));
214            this.element.dispatchEvent(new CustomEvent('affixed.bs.affix', { bubbles: true }));
215        } else if (state === 'top') {
216            this.element.dispatchEvent(new CustomEvent('affix-top.bs.affix', { bubbles: true }));
217            this.element.dispatchEvent(new CustomEvent('affixed-top.bs.affix', { bubbles: true }));
218        } else if (state === 'bottom') {
219            this.element.dispatchEvent(new CustomEvent('affix-bottom.bs.affix', { bubbles: true }));
220            this.element.dispatchEvent(new CustomEvent('affixed-bottom.bs.affix', { bubbles: true }));
221        }
222    }
223
224    // Static method to initialize all affix elements
225    static initAll() {
226        document.querySelectorAll('[data-spy="affix"]').forEach(el => {
227            if (!el._affix) {
228                el._affix = new Affix(el);
229            }
230        });
231    }
232
233    // Static method to get affix instance from element
234    static getInstance(element) {
235        return element._affix || null;
236    }
237}
238
239// Initialize on DOMContentLoaded
240if (document.readyState === 'loading') {
241    document.addEventListener('DOMContentLoaded', () => Affix.initAll());
242} else {
243    Affix.initAll();
244}
245
246// Export for module systems
247if (typeof module !== 'undefined' && module.exports) {
248    module.exports = Affix;
249}
250
251// Make globally available
252window.Affix = Affix;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.