1/** 2 * Feature Comparison Table â Accordion Script 3 * 4 * RESPONSIBILITY 5 * ââââââââââââââ 6 * This script handles ONLY the accordion open/close behaviour. 7 * No data fetching, no rendering â that's all done server-side in PHP. 8 * 9 * WHAT IT DOES: 10 * 1. On load, sets the initial height of closed section bodies to 0 11 * so they're hidden (without relying on display:none, which prevents 12 * smooth animation). 13 * 14 * 2. When a section header is clicked: 15 * - Measures the section body's natural (scrollHeight) height. 16 * - Animates from 0px â measuredHeight (opening) or reverse (closing). 17 * - Updates aria-expanded for screen reader accessibility. 18 * - Rotates the chevron via CSS class toggle. 19 * 20 * 3. Prevents section link clicks (â) from toggling the accordion â 21 * those should navigate to the linked page. 22 * 23 * 4. Supports keyboard navigation: Enter and Space trigger toggle. 24 * 25 * 5. Re-initialises when Elementor's frontend rerenders (editor preview). 26 * 27 * WHY NOT CSS MAX-HEIGHT ANIMATION? 28 * ââââââââââââââââââââââââââââââââââ 29 * The common CSS trick is: transition: max-height 0.3s. The problem is 30 * you must set max-height to a large arbitrary value (e.g. 2000px) to 31 * accommodate any content height. This makes the easing feel wrong because 32 * CSS animates from 0 â 2000px even if the content is only 200px tall, 33 * making the animation feel fast at first and then seemingly pause. 34 * 35 * Our JS approach measures the EXACT height using scrollHeight, then 36 * animates height from 0 â exactPx. The CSS transition in style.css 37 * handles the actual animation (transition: height 0.28s ease). 38 * 39 * ANIMATION CLEANUP: 40 * After the opening animation completes (transitionend), we set height 41 * back to 'auto'. This allows the section to grow if the viewport resizes 42 * or if dynamic content is added later. 43 * 44 * @package BFCT 45 */ 46 47( function( $ ) { 48 'use strict'; 49 50 // ââ Constants âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 51 var SELECTOR_WRAPPER = '.bfct-wrapper'; 52 var SELECTOR_SECTION = '.bfct-section'; 53 var SELECTOR_HEADER = '.bfct-section-header'; 54 var SELECTOR_BODY = '.bfct-section-body'; 55 var SELECTOR_LINK = '.bfct-section-link'; 56 var CLASS_OPEN = 'bfct-section--open'; 57 58 // ââ Mobile Edition Switcher constants ââââââââââââââââââââââââââââââââââââ 59 var SELECTOR_SWITCHER = '.bfct-mobile-switcher'; 60 var SELECTOR_SWITCHER_OPTION = '.bfct-mobile-switcher__option'; 61 var SELECTOR_VALUE_CELL = '.bfct-cell--icon, .bfct-cell--text'; 62 var CLASS_SWITCHER_ACTIVE = 'bfct-mobile-switcher__option--active'; 63 var CLASS_CELL_MOBILE_HIDDEN = 'bfct-cell--mobile-hidden'; 64 65 /** 66 * initTable( $wrapper ) 67 * 68 * Initialises accordion behaviour for a single .bfct-wrapper instance. 69 * Called for each widget on the page, and again when Elementor re-renders. 70 * 71 * @param {jQuery} $wrapper The .bfct-wrapper element. 72 */ 73 function initTable( $wrapper ) { 74 75 // ââ Step 1: Set initial heights ââââââââââââââââââââââââââââââââââââââââ 76 /** 77 * For sections that start CLOSED: 78 * Set height to '0px' explicitly so the CSS transition has a 79 * defined start point. Without this, the first open animation 80 * won't animate (there's no 'from' value to transition from). 81 * 82 * For sections that start OPEN (have .bfct-section--open): 83 * Set height to 'auto' so the content is fully visible and 84 * the section can resize naturally. 85 */ 86 $wrapper.find( SELECTOR_SECTION ).each( function() { 87 var $section = $( this ); 88 var $body = $section.find( SELECTOR_BODY ); 89 90 if ( $section.hasClass( CLASS_OPEN ) ) { 91 // Open: let it be its natural height. 92 $body.css( 'height', 'auto' ); 93 } else { 94 // Closed: collapse to zero, no animation on init. 95 // We temporarily disable transition so this happens instantly. 96 $body.css({ 97 'transition': 'none', 98 'height': '0px', 99 'overflow': 'hidden' 100 }); 101 102 // Re-enable transition on the next frame. 103 // requestAnimationFrame ensures the 'none' transition has 104 // been applied before we restore it. 105 requestAnimationFrame( function() { 106 $body.css( 'transition', '' ); // Restores the CSS-defined transition 107 }); 108 } 109 }); 110 111 // ââ Step 2: Section header click handler âââââââââââââââââââââââââââââââ 112 /** 113 * We use event delegation on the wrapper rather than binding directly 114 * to each header. This is more efficient and works correctly when 115 * Elementor re-renders the widget in the editor. 116 * 117 * .off() removes any previously bound handler before adding a new one, 118 * preventing duplicate handlers on re-init. 119 */ 120 $wrapper 121 .off( 'click.bfct', SELECTOR_HEADER ) 122 .on( 'click.bfct', SELECTOR_HEADER, function( event ) { 123 124 // ââ Guard: ignore clicks on the section link (â) ââââââââââââââ 125 // The .bfct-section-link is a real <a> tag inside the header. 126 // We want clicks on IT to navigate, not toggle the accordion. 127 // event.target is the actual element clicked (may be a child). 128 // .closest() checks if the click was on or inside the link. 129 if ( $( event.target ).closest( SELECTOR_LINK ).length ) { 130 return; // Let the default link behaviour proceed. 131 } 132 133 var $header = $( this ); 134 var $section = $header.closest( SELECTOR_SECTION ); 135 var $body = $section.find( SELECTOR_BODY ); 136 var isOpen = $section.hasClass( CLASS_OPEN ); 137 138 if ( isOpen ) { 139 closeSection( $section, $header, $body ); 140 } else { 141 openSection( $section, $header, $body ); 142 } 143 }); 144 145 // ââ Step 3: Keyboard accessibility âââââââââââââââââââââââââââââââââââââ 146 /** 147 * The section header has tabindex="0" in the PHP template, making it 148 * focusable. We listen for Enter and Space to trigger the same toggle 149 * behaviour as a mouse click. 150 * 151 * This makes the accordion fully keyboard-navigable without needing 152 * a real <button> element (which would complicate the header layout). 153 */ 154 $wrapper 155 .off( 'keydown.bfct', SELECTOR_HEADER ) 156 .on( 'keydown.bfct', SELECTOR_HEADER, function( event ) { 157 // key codes: Enter = 13, Space = 32 158 if ( event.key === 'Enter' || event.key === ' ' || event.keyCode === 13 || event.keyCode === 32 ) { 159 event.preventDefault(); // Prevent page scroll on Space. 160 $( this ).trigger( 'click' ); 161 } 162 }); 163 164 // ââ Step 4: Prevent link click from bubbling to header âââââââââââââââââ 165 /** 166 * Even though we handle this in the click guard above, explicitly 167 * stopping propagation on the link provides a cleaner separation. 168 * Without this, clicks on the link could reach other delegated handlers. 169 */ 170 $wrapper 171 .off( 'click.bfct-link', SELECTOR_LINK ) 172 .on( 'click.bfct-link', SELECTOR_LINK, function( event ) { 173 event.stopPropagation(); 174 // Default behaviour (navigation) continues. 175 }); 176 177 // ââ Step 5: Mobile edition switcher âââââââââââââââââââââââââââââââââââââ 178 // Separate function â see initEditionSwitcher() below for the full 179 // explanation of how it shows/hides value cells per edition. 180 initEditionSwitcher( $wrapper ); 181 } 182 183 /** 184 * initEditionSwitcher( $wrapper ) 185 * 186 * Wires up the mobile pill switcher (.bfct-mobile-switcher) that lets 187 * someone pick ONE edition to view at a time on small screens. Does 188 * nothing if the wrapper has no switcher in it (e.g. CSS was modified 189 * to remove it) â safe to call unconditionally from initTable(). 190 * 191 * HOW IT WORKS 192 * ââââââââââââ 193 * Every value cell in the table (.bfct-cell--icon / .bfct-cell--text) 194 * carries a data-edition-index attribute set in templates/render.php, 195 * matching its position among the edition columns (0, 1, 2â¦). Each pill 196 * in the switcher carries the same attribute. Clicking a pill: 197 * 1. Marks that pill active (visually + aria-checked). 198 * 2. Adds .bfct-cell--mobile-hidden to every value cell whose index 199 * does NOT match the clicked pill, and removes it from the one 200 * that does. CSS (only inside the @media max-width:768px block) 201 * hides anything carrying that class â desktop is unaffected 202 * since the class has no effect at wider viewports. 203 * 204 * This runs purely on click â there's no need to recompute anything 205 * on resize, since the CSS itself only acts on the class at mobile 206 * widths regardless of when it was added. 207 * 208 * @param {jQuery} $wrapper The .bfct-wrapper element. 209 */ 210 function initEditionSwitcher( $wrapper ) { 211 var $switcher = $wrapper.find( SELECTOR_SWITCHER ); 212 213 if ( ! $switcher.length ) { 214 return; // No switcher in this instance â nothing to wire up. 215 } 216 217 var $options = $switcher.find( SELECTOR_SWITCHER_OPTION ); 218 var $cells = $wrapper.find( SELECTOR_VALUE_CELL ); 219 220 /** 221 * setActiveEdition( index ) 222 * 223 * Updates both the switcher's visual/ARIA state and every value 224 * cell's visibility to reflect the newly selected edition index. 225 */ 226 function setActiveEdition( index ) { 227 228 // Update the pills: exactly one becomes active. 229 $options.each( function() { 230 var $opt = $( this ); 231 var optIndex = parseInt( $opt.attr( 'data-edition-index' ), 10 ); 232 var isActive = ( optIndex === index ); 233 234 $opt.toggleClass( CLASS_SWITCHER_ACTIVE, isActive ); 235 $opt.attr( 'aria-checked', isActive ? 'true' : 'false' ); 236 }); 237 238 // Update every value cell across the whole table: show the one 239 // matching this edition, hide all others. 240 $cells.each( function() { 241 var $cell = $( this ); 242 var cellIndex = parseInt( $cell.attr( 'data-edition-index' ), 10 ); 243 var shouldHide = ( cellIndex !== index ); 244 245 $cell.toggleClass( CLASS_CELL_MOBILE_HIDDEN, shouldHide ); 246 }); 247 } 248 249 // ââ Click handler ââââââââââââââââââââââââââââââââââââââââââââââââââââ 250 // Event delegation on $switcher (not each $option individually) so 251 // this keeps working correctly if Elementor re-renders the widget. 252 $switcher 253 .off( 'click.bfct', SELECTOR_SWITCHER_OPTION ) 254 .on( 'click.bfct', SELECTOR_SWITCHER_OPTION, function() { 255 var index = parseInt( $( this ).attr( 'data-edition-index' ), 10 );
256 setActiveEdition( index ); 257 }); 258 259 // ââ Default state ââââââââââââââââââââââââââââââââââââââââââââââââââââ 260 // Always start on the first edition (index 0) on load. The PHP 261 // template already marks the first pill active and shows only its 262 // cells server-side, so this call mainly keeps JS state in sync â 263 // but running it explicitly also self-heals if Elementor's editor 264 // re-renders the widget mid-session and the DOM gets reset. 265 setActiveEdition( 0 ); 266 } 267 268 /** 269 * openSection( $section, $header, $body ) 270 * 271 * Animates a section open: 272 * 1. Remove overflow:hidden temporarily (set in closed state). 273 * 2. Measure the natural height via scrollHeight. 274 * 3. Animate height from 0 â measured value. 275 * 4. On animation complete, set height:'auto' so it reflows naturally. 276 * 5. Update classes and ARIA attribute. 277 */ 278 function openSection( $section, $header, $body ) { 279 var bodyEl = $body[0]; // Native DOM element (scrollHeight is a DOM property) 280 281 // Update state immediately (so CSS transition target is correct). 282 $section.addClass( CLASS_OPEN ); 283 $header.attr( 'aria-expanded', 'true' ); 284 285 // Ensure overflow is hidden during animation so content doesn't flash. 286 $body.css( 'overflow', 'hidden' ); 287 288 // Measure the full natural height. 289 // scrollHeight returns the height the element WOULD be if not constrained. 290 var targetHeight = bodyEl.scrollHeight + 'px'; 291 292 // Set target height â the CSS transition animates this change. 293 $body.css( 'height', targetHeight ); 294 295 // After the animation completes, switch to 'auto' height. 296 // This allows the section to expand/contract if content changes later 297 // (e.g. images load, viewport resizes). 298 bodyEl.addEventListener( 'transitionend', function onTransitionEnd( e ) { 299 // Only react to the 'height' property transition ending. 300 if ( e.propertyName !== 'height' ) return; 301 302 $body.css({ 303 'height': 'auto', 304 'overflow': '' // Remove inline overflow, let CSS take over. 305 }); 306 307 // Remove listener to avoid stacking up multiple handlers. 308 bodyEl.removeEventListener( 'transitionend', onTransitionEnd ); 309 }); 310 } 311 312 /** 313 * closeSection( $section, $header, $body ) 314 * 315 * Animates a section closed: 316 * 1. Measure current rendered height (it may be 'auto'). 317 * 2. Set that explicit pixel value so CSS knows where to animate FROM. 318 * 3. On the next frame, set height to 0 to trigger the animation. 319 * 4. Update classes and ARIA attribute. 320 */ 321 function closeSection( $section, $header, $body ) { 322 var bodyEl = $body[0]; 323 324 // Capture the current rendered height as a pixel value. 325 // We need this because we can't animate FROM 'auto'. 326 var currentHeight = bodyEl.getBoundingClientRect().height + 'px'; 327 328 // Set height to explicit pixels â required as animation start point. 329 $body.css({ 330 'height': currentHeight, 331 'overflow': 'hidden' 332 }); 333 334 // Update state (class + aria) before animation. 335 $section.removeClass( CLASS_OPEN ); 336 $header.attr( 'aria-expanded', 'false' ); 337 338 // Tiny delay (one animation frame) ensures the browser has painted 339 // the explicit height before we transition to 0. 340 // Without this delay, some browsers skip the animation entirely. 341 requestAnimationFrame( function() { 342 requestAnimationFrame( function() { 343 // Now animate to zero â CSS transition handles the easing. 344 $body.css( 'height', '0px' ); 345 }); 346 }); 347 } 348 349 /** 350 * init() 351 * 352 * Finds all .bfct-wrapper elements on the page and initialises each one. 353 * Called both on DOM ready and when Elementor rerenders in the editor. 354 */
355 function init() { 356 $( SELECTOR_WRAPPER ).each( function() { 357 initTable( $( this ) ); 358 }); 359 } 360 361 // ââ Entry Points ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 362 363 /** 364 * Standard DOM ready â initialises on all normal (non-Elementor) page loads. 365 */ 366 $( document ).ready( function() { 367 init(); 368 }); 369 370 /** 371 * Elementor frontend init hook. 372 * 373 * Elementor fires 'elementor/frontend/init' after its own JS has 374 * initialised on the frontend. For the editor specifically, it also 375 * fires events when widgets are added or re-rendered. We hook into both 376 * to ensure the accordion works in: 377 * - Normal page load (frontend) 378 * - Elementor editor preview 379 * - After Elementor re-renders the widget (settings change in editor) 380 */ 381 $( window ).on( 'elementor/frontend/init', function() { 382 383 if ( typeof elementorFrontend !== 'undefined' && elementorFrontend.hooks ) { 384 385 /** 386 * 'frontend/element_ready/bfct-feature-table.default' 387 * 388 * Fires whenever an instance of our specific widget is ready 389 * in the DOM â both on the frontend and in the editor preview. 390 * The widget's get_name() value ('bfct-feature-table') forms 391 * part of the hook name. 392 * 393 * $scope is the jQuery object wrapping the .elementor-widget element. 394 * We find our .bfct-wrapper inside it. 395 */ 396 elementorFrontend.hooks.addAction( 397 'frontend/element_ready/bfct-feature-table.default', 398 function( $scope ) { 399 var $wrapper = $scope.find( SELECTOR_WRAPPER ); 400 if ( $wrapper.length ) { 401 initTable( $wrapper ); 402 } 403 } 404 ); 405 } 406 }); 407 408} )( jQuery );
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.