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.