1/** 2 * @file 3 * Contains utility functions for Solo module. 4 * 5 * Filename: solo-utils.js 6 * Website: https://www.flashwebcenter.com 7 * Developer: Alaa Haddad https://www.alaahaddad.com. 8 */ 9((Drupal, drupalSettings, once) => { 10 'use strict'; 11 12 /** 13 * Solo helper functions namespace. 14 * 15 * @namespace 16 */ 17 Drupal.solo = Drupal.solo || {}; 18 19 /** 20 * Safely escapes CSS selectors with IE11 fallback 21 */ 22 Drupal.solo.escapeSelector = (str) => { 23 if (!str) return ''; 24 25 if (typeof CSS !== 'undefined' && CSS.escape) { 26 try { 27 return CSS.escape(str); 28 } catch (e) { 29 console.warn('Solo: CSS.escape failed', e); 30 } 31 } 32 33 // Fallback for IE11 and edge cases 34 return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); 35 }; 36 37 /** 38 * Animation queue with automatic cleanup 39 */ 40 Drupal.solo.animationQueue = (() => { 41 const queue = new Map(); 42 const cleanupHandlers = new Map(); 43 44 const add = (elementKey, type, duration) => { 45 queue.set(elementKey, type); 46 47 // Auto-cleanup after duration + buffer 48 const timeoutId = setTimeout(() => { 49 queue.delete(elementKey); 50 cleanupHandlers.delete(elementKey); 51 }, duration + 100); 52 53 // Cleanup on page navigation 54 const cleanup = () => { 55 clearTimeout(timeoutId); 56 queue.delete(elementKey); 57 cleanupHandlers.delete(elementKey); 58 }; 59 60 cleanupHandlers.set(elementKey, cleanup); 61 window.addEventListener('pagehide', cleanup, { once: true }); 62 63 return cleanup; 64 }; 65 66 const has = (elementKey) => queue.has(elementKey); 67 68 const clear = (elementKey) => { 69 const cleanup = cleanupHandlers.get(elementKey); 70 if (cleanup) cleanup(); 71 }; 72 73 const clearAll = () => { 74 cleanupHandlers.forEach(cleanup => cleanup()); 75 queue.clear(); 76 cleanupHandlers.clear(); 77 }; 78 79 return { add, has, clear, clearAll }; 80 })(); 81 82 /** 83 * Observes DOM mutations and cleans up orphaned event listeners 84 */ 85 Drupal.solo.setupListenerCleanup = (handlerMap) => { 86 if (!handlerMap || typeof MutationObserver === 'undefined') return null; 87 88 const observer = new MutationObserver((mutations) => { 89 mutations.forEach((mutation) => { 90 mutation.removedNodes.forEach((node) => { 91 if (node.nodeType === 1 && node.querySelectorAll) { 92 // Clean up event listeners on removed elements 93 const buttons = node.querySelectorAll('.dropdown-toggler'); 94 buttons.forEach(btn => { 95 const handler = handlerMap.get(btn); 96 if (handler) { 97 btn.removeEventListener('click', handler); 98 handlerMap.delete(btn); 99 } 100 }); 101 } 102 }); 103 }); 104 }); 105 106 observer.observe(document.body, { 107 childList: true, 108 subtree: true 109 }); 110 111 return observer; 112 }; 113 114 /** 115 * Sets inert state with fallback for Safari < 15.5 116 */ 117 Drupal.solo.setInert = (element, value) => { 118 if (!element || !(element instanceof HTMLElement)) return; 119 120 if ('inert' in HTMLElement.prototype) { 121 element.inert = value; 122 } else { 123 // Fallback for older browsers 124 element.setAttribute('aria-hidden', value ? 'true' : 'false'); 125 126 if (value) { 127 element.querySelectorAll('a, button, input, select, textarea, [tabindex]').forEach(el => { 128 const currentTabindex = el.getAttribute('tabindex'); 129 el.setAttribute('data-original-tabindex', currentTabindex || '0'); 130 el.setAttribute('tabindex', '-1'); 131 }); 132 } else { 133 element.querySelectorAll('[data-original-tabindex]').forEach(el => { 134 const original = el.getAttribute('data-original-tabindex'); 135 if (original === '0' || original === null) { 136 el.removeAttribute('tabindex'); 137 } else { 138 el.setAttribute('tabindex', original); 139 } 140 el.removeAttribute('data-original-tabindex'); 141 }); 142 } 143 } 144 }; 145 146 Drupal.solo.animations = { 147 slideUp: drupalSettings.solo?.slideUpSpeed || 350, 148 slideDown: drupalSettings.solo?.slideDownSpeed || 500, 149 megaMenu: drupalSettings.solo?.megaMenuSpeed || 800, 150 reset: drupalSettings.solo?.slideUpSpeed || 350, 151 clickDelay: 500 152 }; 153 154 const animations = Drupal.solo.animations; 155 const prefersReducedMotion = () => window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 156 157 const BREAKPOINTS = { 158 XS: 576, 159 SM: 768, 160 MD: 992, 161 LG: 1200, 162 XL: 1400 163 }; 164 165 const SELECTORS = { 166 PAGE_WRAPPER: '.page-wrapper', 167 SOLO_MENUS: '.solo-inner nav ul:not(.field ul)', 168 SOLO_MENUS_ALL: '.solo-inner nav ul', 169 FILE_UPLOAD_WRAPPER: '.solo-file-upload-wrapper', 170 FILE_INPUT: '.solo-file-native', 171 FILE_NAME: '.solo-file-name', 172 SEARCH_DETAILS: '.search-form details.search-advanced', 173 DETAILS_WRAPPER: '.details-wrapper' 174 }; 175 176 // Store active animations for cleanup (Map instead of WeakMap so cleanup() can iterate). 177 const activeAnimations = new Map(); 178 179 /**
180 * CSS styles for collapsed state. 181 * @const {Object} 182 */ 183 const cssStyles = { 184 overflow: 'hidden', 185 height: '0', 186 paddingTop: '0', 187 paddingBottom: '0', 188 marginTop: '0', 189 marginBottom: '0' 190 }; 191 192 /** 193 * Removes inline styles from an element. 194 * @param {HTMLElement} target - The target element. 195 */ 196 const removeStyles = (target) => { 197 if (!target || !(target instanceof HTMLElement)) { 198 console.warn('Solo: Invalid target element provided to removeStyles'); 199 return; 200 } 201 202 // Hyphenated property names for removeProperty(); transform for iOS compositor layer cleanup. 203 const props = ['height', 'padding-top', 'padding-bottom', 'margin-top', 'margin-bottom', 'overflow', 'transition-duration', 'transition-property', 'transition-timing-function', 'box-sizing', 'transform', '-webkit-transform']; 204 205 try { 206 props.forEach((p) => target.style.removeProperty(p)); 207 } catch (error) { 208 console.error('Solo: Error removing styles', error); 209 } 210 }; 211 Drupal.solo.removeStyles = removeStyles; 212 213 /** 214 * Updates tabindex for menu items based on state. 215 * Uses state manager for coordination. 216 * @param {HTMLElement} target - The target container element. 217 * @param {boolean} isOpen - Whether the menu is open. 218 * @param {string} componentName - The component making the change. 219 */ 220 const updateTabindex = (target, isOpen, componentName = 'utils') => { 221 if (!target || !(target instanceof HTMLElement)) { 222 return; 223 } 224 225 try { 226 if (Drupal.solo.menuState) { 227 Drupal.solo.menuState.updateMenuTabindex(target, isOpen, componentName); 228 } else { 229 // Fallback for backward compatibility 230 const firstLevelItems = target.querySelectorAll(':scope > li > a, :scope > li > button'); 231 firstLevelItems.forEach(item => { 232 item.setAttribute('tabindex', isOpen ? '0' : '-1'); 233 }); 234 } 235 } catch (error) { 236 console.error('Solo: Error updating tabindex', error); 237 } 238 }; 239 240 /** 241 * Clears animation timeout for an element. 242 * @param {HTMLElement} element - The element with active animation. 243 */ 244 const clearAnimationTimeout = (element) => { 245 const timeoutId = activeAnimations.get(element); 246 if (timeoutId) { 247 clearTimeout(timeoutId); 248 activeAnimations.delete(element); 249 } 250 }; 251 252 /** 253 * Slides up an element with animation. 254 * @param {HTMLElement} target - The target element to slide up. 255 * @param {number} [duration=600] - Animation duration in milliseconds. 256 * @param {string} [componentName='utils'] - The component making the change. 257 * @returns {boolean} Success status. 258 */ 259 /** 260 * Slides up an element with animation. 261 * @param {HTMLElement} target - The target element to slide up. 262 * @param {number} [duration=600] - Animation duration in milliseconds. 263 * @param {string} [componentName='utils'] - The component making the change. 264 * @param {boolean} [announce=false] - Whether to announce state change to screen readers. 265 * @returns {boolean} Success status. 266 */ 267 const slideUp = (target, duration = animations.slideUp, componentName = 'utils', announce = false) => { 268 if (!target || !(target instanceof HTMLElement)) { 269 console.warn('Solo: Invalid target element provided to slideUp'); 270 return false; 271 } 272 273 // Clear any existing animation 274 clearAnimationTimeout(target); 275 276 if (prefersReducedMotion()) { 277 target.classList.remove('toggled'); 278 if (Drupal.solo.menuState) { 279 Drupal.solo.menuState.setHidden(target, true, componentName); 280 } else { 281 target.setAttribute('aria-hidden', 'true'); 282 } 283 updateTabindex(target, false, componentName); 284 target.style.display = 'none'; 285 removeStyles(target); 286 287 // FIXED: Only announce if explicitly requested 288 if (announce && Drupal.announce) { 289 Drupal.announce(Drupal.t('Content collapsed')); 290 } 291 return true; 292 } 293 294 try { 295 target.style.transitionProperty = 'height, margin, padding'; 296 target.style.transitionDuration = `${duration}ms`; 297 target.style.transitionTimingFunction = 'ease-in-out'; 298 target.style.boxSizing = 'border-box'; 299 target.style.height = `${target.offsetHeight}px`; 300 target.style.webkitTransform = 'translateZ(0)'; 301 target.style.transform = 'translateZ(0)'; 302 void target.offsetHeight; 303 304 target.classList.remove('toggled'); 305 if (Drupal.solo.menuState) { 306 Drupal.solo.menuState.setHidden(target, true, componentName); 307 } else { 308 target.setAttribute('aria-hidden', 'true'); 309 } 310 updateTabindex(target, false, componentName); 311 312 // iOS Safari: apply collapse in next frame so "from" state is painted and transition runs. 313 const applySlideUpEnd = () => {
314 Object.keys(cssStyles).forEach(style => { 315 target.style[style] = cssStyles[style]; 316 }); 317 }; 318 requestAnimationFrame(applySlideUpEnd); 319 320 const timeoutId = setTimeout(() => { 321 if (target && target.parentNode) { 322 target.style.display = 'none'; 323 removeStyles(target); 324 activeAnimations.delete(target); 325 326 // FIXED: Only announce if explicitly requested 327 if (announce && Drupal.announce) { 328 Drupal.announce(Drupal.t('Content collapsed')); 329 } 330 } 331 }, duration + 20); 332 333 activeAnimations.set(target, timeoutId); 334 return true; 335 } catch (error) { 336 console.error('Solo: Error in slideUp animation', error); 337 return false; 338 } 339 }; 340 Drupal.solo.slideUp = slideUp; 341 342 /** 343 * Slides down an element with animation. 344 * @param {HTMLElement} target - The target element to slide down. 345 * @param {number} [duration=600] - Animation duration in milliseconds. 346 * @param {string} [menuDisplay='block'] - Display value when shown. 347 * @param {string} [componentName='utils'] - The component making the change. 348 * @param {boolean} [announce=false] - Whether to announce state change to screen readers. 349 * @returns {boolean} Success status. 350 */ 351 const slideDown = (target, duration = animations.slideDown, menuDisplay = 'block', componentName = 'utils', announce = false) => { 352 if (!target || !(target instanceof HTMLElement)) { 353 console.warn('Solo: Invalid target element provided to slideDown'); 354 return false; 355 } 356 357 // Clear any existing animation 358 clearAnimationTimeout(target); 359 360 if (prefersReducedMotion()) { 361 target.style.display = menuDisplay; 362 target.classList.add('toggled'); 363 if (Drupal.solo.menuState) { 364 Drupal.solo.menuState.setAriaAttribute(target, 'aria-hidden', 'false', componentName); 365 } else { 366 target.setAttribute('aria-hidden', 'false'); 367 } 368 updateTabindex(target, true, componentName); 369 removeStyles(target); 370 371 // FIXED: Only announce if explicitly requested 372 if (announce && Drupal.announce) { 373 Drupal.announce(Drupal.t('Content expanded')); 374 } 375 return true; 376 } 377 378 try { 379 target.style.removeProperty('display'); 380 let currentDisplay = window.getComputedStyle(target).display; 381 382 if (currentDisplay === 'none') { 383 currentDisplay = menuDisplay; 384 } 385 386 target.style.display = menuDisplay; 387 let height = target.offsetHeight; 388 height = Math.round(height); 389 390 // Apply collapsed "from" state without transition. 391 Object.keys(cssStyles).forEach(style => { 392 target.style[style] = cssStyles[style]; 393 }); 394 target.style.boxSizing = 'border-box'; 395 396 // Force reflow to paint the collapsed state before enabling transition. 397 void target.offsetHeight; 398 399 // Now enable transition and GPU layer promotion. 400 target.style.transitionProperty = 'height, margin, padding'; 401 target.style.transitionDuration = `${duration}ms`; 402 target.style.transitionTimingFunction = 'ease-in-out'; 403 target.style.webkitTransform = 'translateZ(0)'; 404 target.style.transform = 'translateZ(0)'; 405 406 // iOS Safari: set expanded height in next frame so transition runs from 0 â target. 407 const applySlideDownEnd = () => { 408 if (!target.parentNode) return; 409 target.style.height = `${height}px`; 410 target.classList.add('toggled'); 411 if (Drupal.solo.menuState) { 412 Drupal.solo.menuState.setAriaAttribute(target, 'aria-hidden', 'false', componentName); 413 } else { 414 target.setAttribute('aria-hidden', 'false'); 415 } 416 updateTabindex(target, true, componentName); 417 ['padding-top', 'padding-bottom', 'margin-top', 'margin-bottom'].forEach(property => { 418 target.style.removeProperty(property); 419 }); 420 }; 421 requestAnimationFrame(applySlideDownEnd); 422 423 const timeoutId = setTimeout(() => { 424 if (target && target.parentNode) { 425 ['height', 'overflow', 'transition-duration', 'transition-property', 'transition-timing-function', 'box-sizing', 'transform', '-webkit-transform'].forEach(property => 426 target.style.removeProperty(property)); 427 activeAnimations.delete(target); 428 if (announce && Drupal.announce) { 429 Drupal.announce(Drupal.t('Content expanded')); 430 } 431 } 432 }, duration + 20); 433 activeAnimations.set(target, timeoutId); 434 return true; 435 } catch (error) { 436 console.error('Solo: Error in slideDown animation', error); 437 return false;
438 } 439 }; 440 Drupal.solo.slideDown = slideDown; 441 442 /** 443 * Toggles slide animation on an element. 444 * @param {HTMLElement} target - The target element to toggle. 445 * @param {number} [duration=500] - Animation duration in milliseconds. 446 * @param {string} [componentName='utils'] - The component making the change. 447 * @returns {boolean} Success status. 448 */ 449 const slideToggle = (target, duration = animations.slideUp, componentName = 'utils') => { 450 if (!target || !(target instanceof HTMLElement)) { 451 console.warn('Solo: Invalid target element provided to slideToggle'); 452 return false; 453 } 454 455 // Prevent rapid toggling during animation 456 if (activeAnimations.has(target)) { 457 return false; 458 } 459 460 if (!target.classList.contains('toggled')) { 461 return slideDown(target, duration, 'flex', componentName); 462 } else { 463 return slideUp(target, duration, componentName); 464 } 465 }; 466 Drupal.solo.slideToggle = slideToggle; 467 468 /** 469 * Gets the current viewport width. 470 * Uses state manager if available for consistency. 471 * @returns {number} Current width in pixels. 472 */ 473 const getCurrentWidth = () => { 474 if (Drupal.solo.menuState) { 475 return Drupal.solo.menuState.getCurrentWidth(); 476 } 477 return window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth || 0; 478 }; 479 480 /** 481 * Calculates the depth of a menu element. 482 * @param {HTMLElement} element - The menu element. 483 * @returns {number} The depth level (starts at 1). 484 */ 485 const calculateDepth = (element) => { 486 if (!element || !(element instanceof HTMLElement)) { 487 return 1; 488 } 489 490 let depth = 1; 491 let parent = element.parentElement; 492 493 while (parent) { 494 if (parent.tagName.toLowerCase() === 'ul') { 495 depth++; 496 } 497 parent = parent.parentElement; 498 } 499 500 return depth; 501 }; 502 Drupal.solo.calculateDepth = calculateDepth; 503 504 /** 505 * Adds depth-based classes to menu elements. 506 * @param {HTMLElement} element - The menu element. 507 * @param {number} depth - The depth level. 508 */ 509 const addClassAccordingToDepth = (element, depth) => { 510 if (!element || !(element instanceof HTMLElement)) { 511 return; 512 } 513 514 try { 515 element.classList.add(`ul-${depth}`); 516 517 const children = element.children; 518 for (let i = 0; i < children.length; i++) { 519 if (children[i] instanceof HTMLElement) { 520 children[i].classList.add(`li-${depth}`); 521 } 522 } 523 } catch (error) { 524 console.error('Solo: Error adding depth classes', error); 525 } 526 }; 527 528 /** 529 * Gets breakpoint value from element classes. 530 * @param {HTMLElement} element - The element to check. 531 * @param {string} mn - The breakpoint prefix. 532 * @returns {number} The breakpoint value in pixels. 533 */ 534 const getMyBreakpoints = (element, mn) => { 535 if (!element || !(element instanceof HTMLElement)) { 536 return BREAKPOINTS.MD; // Default breakpoint 537 } 538 539 const classes = [ 540 `${mn}-${BREAKPOINTS.XS}`, 541 `${mn}-${BREAKPOINTS.SM}`, 542 `${mn}-${BREAKPOINTS.MD}`, 543 `${mn}-${BREAKPOINTS.LG}`, 544 `${mn}-${BREAKPOINTS.XL}` 545 ]; 546 547 const classList = Array.from(element.classList); 548 const foundClass = classList.find(c => classes.includes(c)); 549 550 if (foundClass) { 551 const parts = foundClass.split('-'); 552 const value = parseInt(parts[parts.length - 1], 10); 553 return isNaN(value) ? BREAKPOINTS.MD : value; 554 } 555 556 return BREAKPOINTS.MD; 557 }; 558 Drupal.solo.getMyBreakpoints = getMyBreakpoints; 559 560 /** 561 * Get breakpoint number based on the specified class and breakpoint prefix. 562 * @param {string} [breakpointPrefix='mn'] - The prefix for the breakpoint. 563 * @returns {number} The numeric breakpoint value. 564 */ 565 Drupal.solo.getBreakpointNumber = (breakpointPrefix = 'mn') => { 566 const pageClass = document.querySelector(SELECTORS.PAGE_WRAPPER); 567 if (!pageClass) { 568 console.warn('Solo: Page wrapper element not found'); 569 return BREAKPOINTS.MD; 570 } 571 return Drupal.solo.getMyBreakpoints(pageClass, breakpointPrefix); 572 }; 573 574 /** 575 * Determines the current layout based on breakpoints. 576 * @returns {string} 'large' if layout is large, 'small' otherwise. 577 */ 578 const getLayout = () => { 579 const pageClass = document.querySelector(SELECTORS.PAGE_WRAPPER); 580 if (!pageClass) { 581 console.warn('Solo: Page wrapper element not found'); 582 return 'small'; 583 } 584 585 const brNum = Drupal.solo.getMyBreakpoints(pageClass, 'mn'); 586 const currentWidth = getCurrentWidth(); 587 588 return currentWidth > brNum ? 'large' : 'small'; 589 }; 590 Drupal.solo.getLayout = getLayout; 591 592 /** 593 * Drupal behavior to add classes based on menu depth. 594 */ 595 Drupal.behaviors.soloMenuDepth = { 596 attach: (context, settings) => {
597 try { 598 // Option 1: Use the :not() selector (default) 599 const allMenus = once('soloMenuDepth', SELECTORS.SOLO_MENUS_ALL, context); 600 const menus = allMenus.filter((menu) => !menu.closest('.field')); 601 602 menus.forEach((element) => { 603 const depth = calculateDepth(element); 604 addClassAccordingToDepth(element, depth); 605 }); 606 } catch (error) { 607 console.error('Solo: Error in soloMenuDepth behavior', error); 608 } 609 }, 610 detach: (context, settings, trigger) => { 611 if (trigger === 'unload') { 612 try { 613 const allProcessed = once.filter('soloMenuDepth', SELECTORS.SOLO_MENUS_ALL, context); 614 const processedMenus = allProcessed.filter((menu) => !menu.closest('.field')); 615 616 processedMenus.forEach((element) => { 617 Array.from(element.classList).forEach((cn) => { 618 if (/^ul-\d+$/.test(cn)) element.classList.remove(cn); 619 }); 620 Array.from(element.children).forEach((child) => { 621 if (child instanceof HTMLElement) { 622 Array.from(child.classList).forEach((cn) => { 623 if (/^li-\d+$/.test(cn)) child.classList.remove(cn); 624 }); 625 } 626 }); 627 }); 628 629 once.remove('soloMenuDepth', SELECTORS.SOLO_MENUS_ALL, context); 630 } catch (error) { 631 console.error('Solo: Error in soloMenuDepth detach', error); 632 } 633 } 634 } 635 636 }; 637 638 /** 639 * Drupal behavior for file upload enhancement. 640 */ 641 Drupal.behaviors.soloFileUpload = { 642 handlers: new WeakMap(), 643 644 attach(context) { 645 try { 646 const wrappers = once('solo-file-upload', SELECTORS.FILE_UPLOAD_WRAPPER, context); 647 648 wrappers.forEach(wrapper => { 649 const input = wrapper.querySelector(SELECTORS.FILE_INPUT); 650 const fileName = wrapper.querySelector(SELECTORS.FILE_NAME); 651 652 if (!input || !fileName) { 653 console.warn('Solo: File upload elements not found in wrapper'); 654 return; 655 } 656 657 const handler = (event) => { 658 try { 659 if (input.files && input.files.length > 0) { 660 const file = input.files[0]; 661 fileName.textContent = file.name; 662 // Announce file selection to screen readers 663 Drupal.announce(Drupal.t('File selected: @filename', {'@filename': file.name})); 664 } else { 665 fileName.textContent = Drupal.t('No file chosen'); 666 } 667 } catch (error) { 668 console.error('Solo: Error handling file selection', error); 669 fileName.textContent = Drupal.t('Error selecting file'); 670 } 671 }; 672 673 // Store handler reference for cleanup 674 this.handlers.set(input, handler); 675 input.addEventListener('change', handler); 676 }); 677 } catch (error) { 678 console.error('Solo: Error in soloFileUpload behavior', error); 679 } 680 }, 681 682 detach(context, settings, trigger) { 683 if (trigger === 'unload') { 684 try { 685 const wrappers = once.filter('solo-file-upload', SELECTORS.FILE_UPLOAD_WRAPPER, context); 686 687 wrappers.forEach(wrapper => { 688 const input = wrapper.querySelector(SELECTORS.FILE_INPUT); 689 if (input) { 690 const handler = this.handlers.get(input); 691 if (handler) { 692 input.removeEventListener('change', handler); 693 this.handlers.delete(input); 694 } 695 } 696 }); 697 698 once.remove('solo-file-upload', SELECTORS.FILE_UPLOAD_WRAPPER, context); 699 } catch (error) { 700 console.error('Solo: Error in soloFileUpload detach', error); 701 } 702 } 703 } 704 }; 705 706 /** 707 * Drupal behavior for search form animation. 708 */ 709 Drupal.behaviors.soloSearchAnimation = { 710 handlers: new WeakMap(), 711 712 attach: (context, settings) => { 713 const behavior = Drupal.behaviors.soloSearchAnimation; // capture 714 715 try { 716 const detailsElements = once('soloDetailsAnimation', SELECTORS.SEARCH_DETAILS, context);
717 detailsElements?.forEach((detail) => { 718 const wrapper = detail.querySelector(SELECTORS.DETAILS_WRAPPER); 719 if (!wrapper) { 720 console.warn('Solo: Details wrapper not found'); 721 return; 722 } 723 724 // Always ensure transition is defined (whether open or closed initially) 725 wrapper.style.transition = 'height 0.3s ease-in-out, opacity 0.3s ease-in-out'; 726 727 // Initial state 728 if (!detail.open) { 729 wrapper.style.height = '0'; 730 wrapper.style.opacity = '0'; 731 wrapper.style.overflow = 'hidden'; 732 } else { 733 // Ensure we don't end up with "auto" + no transition on first toggle. 734 wrapper.style.overflow = 'hidden'; 735 wrapper.style.height = 'auto'; 736 wrapper.style.opacity = '1'; 737 } 738 739 const transitionEndHandler = (event) => { 740 if (event.target === wrapper && detail.open) { 741 wrapper.style.height = 'auto'; 742 } 743 }; 744 745 const toggleHandler = () => { 746 try { 747 if (detail.open) { 748 const contentHeight = wrapper.scrollHeight + 'px'; 749 // From 0 -> actual height 750 wrapper.style.height = contentHeight; 751 wrapper.style.opacity = '1'; 752 // Defer announcement to avoid timing conflicts 753 setTimeout(() => { 754 Drupal.announce(Drupal.t('Search options expanded')); 755 }, 100); 756 wrapper.addEventListener('transitionend', transitionEndHandler, { once: true }); 757 } else { 758 // From current height -> 0 759 wrapper.style.height = wrapper.scrollHeight + 'px'; 760 requestAnimationFrame(() => { 761 wrapper.style.height = '0'; 762 wrapper.style.opacity = '0'; 763 }); 764 // Defer announcement to avoid timing conflicts 765 setTimeout(() => { 766 Drupal.announce(Drupal.t('Search options collapsed')); 767 }, 100); 768 } 769 } catch (error) { 770 console.error('Solo: Error in search animation', error); 771 } 772 }; 773 774 // Store handlers for cleanup 775 behavior.handlers.set(detail, { toggleHandler, transitionEndHandler }); 776 detail.addEventListener('toggle', toggleHandler); 777 }); 778 } catch (error) { 779 console.error('Solo: Error in soloSearchAnimation behavior', error); 780 } 781 }, 782 783 detach: (context, settings, trigger) => { 784 if (trigger === 'unload') { 785 const behavior = Drupal.behaviors.soloSearchAnimation; 786 787 try { 788 const detailsElements = once.filter('soloDetailsAnimation', SELECTORS.SEARCH_DETAILS, context); 789 detailsElements?.forEach((detail) => { 790 const handlers = behavior.handlers.get(detail); 791 if (handlers) { 792 detail.removeEventListener('toggle', handlers.toggleHandler); 793 const wrapper = detail.querySelector(SELECTORS.DETAILS_WRAPPER); 794 if (wrapper) { 795 wrapper.removeEventListener('transitionend', handlers.transitionEndHandler); 796 } 797 behavior.handlers.delete(detail); 798 } 799 }); 800 once.remove('soloDetailsAnimation', SELECTORS.SEARCH_DETAILS, context); 801 } catch (error) { 802 console.error('Solo: Error in soloSearchAnimation detach', error); 803 } 804 } 805 } 806 }; 807 808 // Cleanup function for module unload 809 if (typeof Drupal.solo.cleanup === 'undefined') { 810 Drupal.solo.cleanup = () => { 811 // Clear all active animations 812 activeAnimations.forEach((timeoutId, element) => { 813 clearTimeout(timeoutId); 814 }); 815 activeAnimations.clear(); 816 }; 817 } 818 819})(Drupal, drupalSettings, once);
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.