PageSourceSearch

https://naehen-schneidern.de/themes/solo/js/components/solo-utils.js?tiuiyu

js naehen-schneidern.de collected 2026-09-25 00:28:20 UTC 27,352 bytes, 819 lines download raw bytes

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.