PageSourceSearch

https://www.baxtercountylibrary.org/modules/custom/lm_menu/js/behaviors.js?v=1.x

js baxtercountylibrary.org collected 2026-10-04 15:06:52 UTC 12,184 bytes, 368 lines download raw bytes

1/**
2 * @file
3 * Provides the menu functionality.
4 */
5
6(Drupal => {
7  /**
8   * The `escape` key ID.
9   *
10   * @var {number}
11   */
12  const ESCAPE = 27;
13
14  /**
15   * The `tab` key ID.
16   *
17   * @var {number}
18   */
19  const TAB = 9;
20
21  /**
22   * Controls menu navigation actions.
23   */
24  class Menu {
25    constructor(context, selector, items) {
26      /**
27       * An abort controller for disabling event listeners.
28       *
29       * The menu event listeners are aborted when the browser is resized.
30       *
31       * @type {AbortController}
32       */
33      this.abortController = new AbortController();
34
35      /**
36       * The HTML element used to run this behavior.
37       *
38       * @type {Document|HTMLElement}
39       */
40      this.context = context;
41
42      /**
43       * The CSS selector for this menu's items.
44       *
45       * @type {string}
46       */
47      this.selector = selector;
48
49      /**
50       * A list of menu item's based on the selector provided.
51       *
52       * @type {NodeListOf<Element>}
53       */
54      this.items = items;
55    }
56
57    /**
58     * Recursively build a list of dropdown menu links
59     *
60     * @param {Element} element
61     *   Current element being added to the element stack.
62     * @param {Element[]} stack
63     *   A list of elements, built up from the deepest item provided.
64     *
65     * @return {Element[]}
66     *   A list menu link elements.
67     */
68    buildParentPath(element) {
69      let stack = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];
70      // Add the link provided to the stack.
71      stack.push(element);
72
73      // Determine this link's hierarchy in the menu. This number will determine
74      // if further recursion is necessary.
75      const level = Number(element.dataset.navLevel);
76      if (level > 0) {
77        // Find this element's dropdown parent ID.
78        const dropdownID = element.dataset.dropdown;
79
80        // Retrieve this elements parent dropdown list.
81        const dropdown = this.context.getElementById(dropdownID);
82
83        // Retrieve the menu item from relative the dropdown menu retrieved.
84        const item = dropdown.parentNode;
85
86        // Find the nav link, associated with the menu item.
87        const link = item.querySelector('.nav-link');
88        return this.buildParentPath(link, stack);
89      }
90      return stack;
91    }
92
93    /**
94     * Filter to remove mega menu children from an list of menu elements.
95     *
96     * @param {Element} element
97     *   The current element in a list of elements
98     *
99     * @return {boolean}
100     *   True if the item is not a mega menu item, otherwise false.
101     */
102    static isNotMegaMenuChild(element) {
103      const potentialMegaMenu = element.parentNode.parentNode;
104      return !potentialMegaMenu.classList.contains('mega-menu-container');
105    }
106
107    /**
108     * Closes a dropdown relative to the element that fired the event.
109     *
110     * @param {MouseEvent} event
111     *   The event `MouseEvent` that triggered this method.
112     */
113    static hide(event) {
114      const element = event.target;
115      Menu.toggle(element, 'remove');
116    }
117
118    /**
119     * Hide any menu item that is currently open.
120     *
121     * @param {Element[]} ignore
122     *   List of optional elements to ignore when hiding items.
123     */
124    hideOpen() {
125      let ignore = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
126      // Create a list of open menu item's containing dropdown menus.
127      const items = this.context.querySelectorAll(`${this.selector}.show`);
128
129      // Filter out any item that exists in the optional ignore list.
130      Array.from(items).filter(element => {
131        const link = element.querySelector('.nav-link');
132        return !ignore.includes(link);
133      }
133).forEach(element => {
134        // Close the menu item.
135        Menu.toggle(element, 'remove');
136      });
137    }
138
139    /**
140     * Loads the event listeners that manage the menu functionality.
141     */
142    loadEvents() {
143      const hoverMediaQuery = matchMedia('(min-width: 992px)');
144      const focusMediaQuery = matchMedia('(min-width: 1024px)');
145      if (hoverMediaQuery.matches) {
146        // Handle hover events for tablets and desktops devices.
147        this.onHover();
148      } else {
149        // Handle click events for mobile devices.
150        this.onClick();
151      }
152
153      // Handle touch and keyboard events for tablets and desktops devices.
154      if (focusMediaQuery.matches) {
155        this.onFocus();
156      }
157
158      // When the hover media query changes, abort all menu events and
159      // recalculate them based on the browser's new size. This is only
160      // for the hover media query because touch start media query will
161      // match in most instances.
162      hoverMediaQuery.addEventListener('change', () => {
163        this.abortController.abort();
164        this.abortController = new AbortController();
165        this.loadEvents();
166      }, {
167        signal: this.abortController.signal
168      });
169    }
170
171    /**
172     * Handles showing and hiding the menu items on click.
173     */
174    onClick() {
175      // Theses selectors will toggle the mobile menu open or closed.
176      const selectors = ['.navbar-nav .dropdown .btn', '.navbar-nav .dropdown span.nav-link'].join(',');
177      this.context.querySelectorAll(selectors).forEach(item => {
178        // Listen for clicks on the mobile menu.
179        item.addEventListener('click', event => {
180          // Toggle the menu open or closed.
181          Menu.toggle(event.currentTarget.parentNode, 'toggle');
182        }, {
183          signal: this.abortController.signal
184        });
185      });
186    }
187
188    /**
189     * Close the all menu items after the menu is no only being focused.
190     */
191    onExit() {
192      // Listen for key events on the body to close the menus when they are no
193      // longer focused.
194      document.querySelector('body').addEventListener('keyup', event => {
195        // Only act on tab events.
196        if (event.which === TAB) {
197          // Retrieve a list of focused links.
198          const links = this.context.querySelectorAll('.nav-link:focus');
199
200          // Hide open menu items if there are no menu links open.
201          if (!links.length) {
202            this.hideOpen();
203          }
204        }
205      }, {
206        signal: this.abortController.signal
207      });
208    }
209
210    /**
211     * Handles showing and hiding the menu items when navigating by keyboard.
212     */
213    onFocus() {
214      // Used to track the nesting level of the currently focused menu item. If
215      // this number is decreased all open menu items (that aren't needed for
216      // displaying the current item) will be closed.
217      let level = 0;
218      this.items.forEach(item => {
219        // Retrieve links pertaining to each menu item, as this is what will be
220        // focused on.
221        const link = item.querySelector('.nav-link');
222
223        // Toggle menu items via the `escape` key.
224        link.addEventListener('keyup', event => {
225          // Check if the `escape` key was pressed.
226          if (event.which === ESCAPE) {
227            // Hide any open menu items.
228            this.hideOpen();
229          }
230        }, {
231          signal: this.abortController.signal
232        });
233
234        // Toggle menu item via focus.
235        link.addEventListener('focusin', event => {
236          // Check if the current item is a dropdown.
237          const isDropdown = item.classList.contains('dropdown');
238          // Check if the current menu item is the child of a mega menu.
239          const notMegaMenuChild = Menu.isNotMegaMenuChild(item);
240          if (isDropdown && notMegaMenuChild) {
241            // Hide any menu items that are not in the stack of the currently
242            // focused item.
243            this.hideOpen(this.buildParentPath(event.currentTarget));
244
245            // Add classes to show the menu item.
246            Menu.toggle(link.parentElement, 'add');
247          }
248
249          // Retrieve the menu level of the newly focused item in order check
250          // against the previous menu level.
251          const newLevel = Number(link.dataset.navLevel);
252
253          // If the menu level is decreased, close any menu lists that do not
254          // need to be open.
255          if (level > newLevel && notMegaMenuChild) {
256            // Hide any menu items that are not in the stack of the currently
257            // focused item.
258            this.hideOpen(this.buildParentPath(event.currentTarget));
259          }
260
261          // Keep track of the new menu level.
262          level = newLevel;
263        }, {
264          signal: this.abortController.signal
265        });
266        this.onExit();
267      });
268    }
269
270    /**
271     * Handles showing and hiding the menu items on hover.
272     */
273    onHover() {
274      this.items.forEach(item => {
275        item.addEventListener('mouseenter', Menu.show, {
276          signal: this.abortController.signal
277        });
278        item.addEventListener('mouseleave', Menu.hide, {
279          signal: this.abortController.signal
280        });
281      });
282    }
283
284    /**
285     * Opens a dropdown relative to the element that fired the event.
286     *
287     * @param {MouseEvent} event
288     *   The event `MouseEvent` that triggered this method.
289     */
290    static show(event) {
291      const element = event.target;
292      Menu.toggle(element, 'add');
293    }
294
295    /**
296     * Shows or hides the dropdown depending on the `action` provided
297     *
298     * @param {Element} element
299     *   The element that trigged the firing of this event.
300     * @param {string} action
301     *   The `add`, `remove`, or `toggle` methods on `classList`.
302     */
303    static toggle(element, action) {
304      // Show or hide the container of the dropdown, should be `.nav-item`.
305      element.classList[action]('show');
306
307      // Mark or un-mark the `.nav-link` as expanded to appease screen
308      // readers.
309      const expanded = action === 'remove' ? 'false' : 'true';
310      const button = element.querySelector(':scope > .btn');
311      if (button) {
312        /** @type {HTMLElement} */
313        const text = button.querySelector('.visually-hidden');
314        text.innerText = element.classList.contains('show') ? Drupal.t('Close Menu') : Drupal.t('Open Menu');
315      }
316
317      // Add show or hide attributes to the elements children. There should be
318      // a `.nav-link`, and a `.dropdown-menu`.
319      Array.from(element.children).forEach(item => {
320        if (item.classList.contains('nav-link')) {
321          // Mark or un-mark the `.nav-link` as expanded to appease screen
322          // readers.
323          if (item.classList.contains('has-children')) {
324            item.setAttribute('aria-expanded', expanded);
325          }
326        } else if (item.classList.contains('dropdown-menu')) {
327          // Show or hide the dropdown menu.
328          item.classList[action]('show');
329        } else if (item.classList.contains('mega-menu-container')) {
330          // Open all sub item's if the current device's viewport with is
331          // greater than the mobile menu breakpoint.
332          if (!matchMedia('(min-width: 992px)').matches) {
333            // Retrieve the inner dropdown menu relative to the current menu
334            // item.
335            const dropdownMenu = item.querySelector('.dropdown-menu');
336
337            // Expand or collapse this menu.
338            dropdownMenu.classList[action]('show');
339          } else {
340            const dropdownMenus = item.querySelectorAll('.dropdown-menu');
341
342            // Open all dropdown menus under this mega menu.
343            dropdownMenus.forEach(child => {
344              child.classList[action]('show');
345
346              // Mark tab-able items within the mega menu as expanded.
347              child.querySelectorAll('.nav-link').forEach(link => {
348                if (link.classList.contains('has-children')) {
349                  link.setAttribute('aria-expanded', expanded);
350                }
351              });
352            });
353          }
354        }
355      });
356    }
357  }
358  Drupal.behaviors.libraryMarketMenu = {
359    attach(context) {
360      const selector = '.navbar-nav .nav-item';
361      const items = once('lm-menu-item', selector, context);
362      if (Array.isArray(items) && items.length) {
363        const menu = new Menu(context, selector, items);
364        menu.loadEvents();
365      }
366    }
367  };
368})(Drupal);

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.