PageSourceSearch

https://themwl.org/modules/contrib/selectify/js/selectify-helper.js?tm84lz

js themwl.org collected 2026-10-02 18:44:14 UTC 24,273 bytes, 540 lines download raw bytes

1/**
2 * @file
3 * Contains utility functions for Selectify module multi-select.
4 *
5 * Filename: selectify-helper.js
6 * Website: https://www.flashwebcenter.com
7 * Developer: Alaa Haddad https://www.alaahaddad.com.
8 */
9((Drupal, drupalSettings) => {
10  'use strict';
11  // Ensure Drupal.selectify namespace exists
12  Drupal.selectify = Drupal.selectify || {};
13  
14  /**
15   * Debug logging helper (only logs if debug mode is enabled).
16   *
17   * @param {string} message - The message to log.
18   * @param {...*} args - Additional arguments to log.
19   */
20  const debugLog = (message, ...args) => {
21    if (drupalSettings?.selectify?.debug) {
22      console.warn('[Selectify]', message, ...args);
23    }
24  };
25  /**
26   * Reports init failure (only when debug is on). Use in widget bootstrap.
27   *
28   * @param {string} widgetName - Widget name for the log (e.g. "Dropdown", "Dual").
29   * @param {string} message - Failure message.
30   */
31  Drupal.selectify.reportInitFailure = (widgetName, message) => {
32    debugLog(`${widgetName}: ${message}`);
33  };
34  /**
35   * Shared widget bootstrap: resolve native select, run init, selection limit, dropdown id/aria.
36   * Call from each behavior after once(selector, context). Keeps dual and dropdown widgets
37   * consistent; dual passes dropdownPanelSelector '.dual-available', others '.selectify-available-display'.
38   *
39   * @param {HTMLElement} selectifySelect - The widget wrapper (already matched by once).
40   * @param {string} widgetName - Name for debug messages (e.g. "Dropdown", "Dual", "Tags").
41   * @param {string} type - Widget type: 'dropdown'|'tags'|'searchable'|'checkbox'|'dual'.
42   * @param {Object} options - Bootstrap options.
43   * @param {function(HTMLElement, HTMLSelectElement)} options.initFn - (selectifySelect, nativeSelect) runs widget-specific init.
44   * @param {string} [options.dropdownPanelSelector] - Selector for the dropdown/available panel to set id and aria-controls (e.g. '.selectify-available-display' or '.dual-available').
45   * @param {string} [options.itemSelector='.selectify-available-one-option'] - Selector for selectable options (handleSelectionLimit).
46   */
47  Drupal.selectify.bootstrapWidget = (selectifySelect, widgetName, type, options) => {
48    const targetId = selectifySelect.getAttribute('data-target-id');
49    if (!targetId) {
50      Drupal.selectify.reportInitFailure(widgetName, 'missing data-target-id attribute');
51      return;
52    }
53    const nativeSelect = document.getElementById(targetId);
54    if (!nativeSelect || nativeSelect.tagName !== 'SELECT') {
55      Drupal.selectify.reportInitFailure(widgetName, `native select element not found: ${targetId}`);
56      return;
57    }
58    if (options.initFn) {
59      options.initFn(selectifySelect, nativeSelect);
60    }
61    let maxSelections = selectifySelect.getAttribute('data-max-selections');
62    maxSelections = maxSelections === 'null' ? null : parseInt(maxSelections, 10);
63    const itemSelector = options.itemSelector || '.selectify-available-one-option';
64    Drupal.selectify.handleSelectionLimit(type, selectifySelect, nativeSelect, maxSelections, itemSelector);
65    if (options.dropdownPanelSelector) {
66      const panel = selectifySelect.querySelector(options.dropdownPanelSelector);
67      if (panel) {
68        panel.setAttribute('id', `${targetId}-dropdown`);
69        selectifySelect.setAttribute('aria-controls', `${targetId}-dropdown`);
70      }
71    }
72    Drupal.selectify.injectAriaLabelledBy();
73  };
74  /**
75   * Shared "Clear All" handler: clear to "All" or deselect all, update UI, dispatch events, focus.
76   * Dual and dropdown widgets pass their own onDeselectAll and focusTarget so behavior stays correct.
77   *
78   * @param {string} type - Widget type: 'dropdown'|'tags'|'searchable'|'checkbox'|'dual'.
79   * @param {HTMLElement} selectifySelect - The widget wrapper.
80   * @param {HTMLSelectElement} nativeSelect - The hidden <select>.
81   * @param {HTMLElement} selectedDisplay - The display area for selected options.
82   * @param {HTMLElement|null} clearAllButton - The Clear button (for updateSelectedDisplay).
83   * @param {HTMLElement|null} dropdownMenu - The available-options panel (for onDeselectAll).
84   * @param {Object} options - Clear options.
85   * @param {function(HTMLElement, HTMLElement, HTMLSelectElement)} [options.onDeselectAll] - (dropdownMenu, selectedDisplay, nativeSelect) widget-specific DOM reset when deselecting all.
86   * @param {function(HTMLElement): (HTMLElement|null)} [options.focusTarget] - (selectifySelect) return element to focus after clear (e.g. selected display or dual available list).
87   */
88  Drupal.selectify.executeClear = (type, selectifySelect, nativeSelect, selectedDisplay, clearAllButton, dropdownMenu, options) => {
89    const targetValues = Drupal.selectify.getClearTargetValues(nativeSelect, selectifySelect);
90    if (targetValues.length > 0) {
91      nativeSelect.querySelectorAll('option').forEach((opt) => {
92        opt.selected = (opt.value === 'All');
93      });
94      Drupal.selectify.syncHiddenSelect(nativeSelect, targetValues);
95    } else {
96      nativeSelect.querySelectorAll('option').forEach((opt) => {
97        opt.selected = false;
98      });
99      if (options.onDeselectAll && dropdownMenu) {
100        options.onDeselectAll(dropdownMenu, selectedDisplay, nativeSelect);
101      }
102      Drupal.selectify.syncHiddenSelect(nativeSelect, []);
103    }
104    Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
105    setTimeout(() => {
106      nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
107      nativeSelect.dispatchEvent(new Event('input', { bubbles: true }));
108    }, 0);
109    const constants = Drupal.selectify?.constants || {};
110    const focusDelay = constants.FOCUS_DELAY || 100;
111    if (options.focusTarget) {
112      const el = options.focusTarget(selectifySelect);
113      if (el && typeof el.focus === 'function') {
114        setTimeout(() => el.focus(), focusDelay);
115      }
116    }
117  };
118  /**
119   * Handles selection limits for multi-select components.
120   * Shows a message with animation when the max selection limit is reached.
121   *
122   * @param {string} type - The type of selectify component (e.g., "checkbox", "dual", "dropdown").
123   * @param {HTMLElement} selectifySelect - The main Selectify wrapper element.
124   * @param {HTMLElement} nativeSelect - The corresponding hidden <select> element.
125   * @param {number|null} maxSelections - The maximum number of selections allowed.
126   * @param {string} itemSelector - The CSS selector for selectable options.
127   */
128  Drupal.selectify.handleSelectionLimit = function(type, selectifySelect, nativeSelect, maxSelections, itemSelector) {
129    if (!Number.isInteger(maxSelections) || maxSelections <= 0) {
130      return;
131    }
132    if (!nativeSelect) {
133      return;
134    }
135    let selectedDisplay = type === 'dual' ?
136      selectifySelect.querySelector('.selectify-selected-display-dual') :
137      selectifySelect.querySelector('.selectify-selected-display');
138    const clearAllButton = selectifySelect.querySelector('.selectify-clear-all');
139    const maxSelectionMessage = selectifySelect.querySelector('.selectify-max-selection-message');
140    const items = selectifySelect.querySelectorAll(itemSelector);
141    let selectedCount = 0;
142
143    function showMaxSelectionMessage() {
144      if (maxSelectionMessage) {
145        const constants = Drupal.selectify?.constants || {};
146        const dismissDelay = constants.MESSAGE_AUTO_DISMISS || 3000;
147        maxSelectionMessage.classList.remove('hide');
148        maxSelectionMessage.classList.add('show');
149        setTimeout(() => {
150          maxSelectionMessage.classList.remove('show');
151          maxSelectionMessage.classList.add('hide');
152        }, dismissDelay);
153      }
154    }
155    if (type === 'checkbox') {
156      const items = selectifySelect.querySelectorAll('.selectify-available-one-option input[type="checkbox"]');
157      once('selectifyCheckboxLimit', items).forEach((item) => {
158        item.addEventListener('change', function() {
159          // For maxSelections=1, uncheck all other checkboxes when checking a new one (click-to-switch, like other widgets)
160          if (maxSelections === 1 && this.checked) {
161            items.forEach(i => {
162              if (i !== this && i.checked) {
163                i.checked = false;
164                const parent = i.closest('.selectify-available-one-option');
165                if (parent) {
166                  parent.classList.remove('s-selected');
167                }
168              }
169            });
170          }
171
172          let selectedItems = [...items].filter(i => i.checked);
173          let selectedCount = selectedItems.length;
174          // Prevent selection if max is reached (except for maxSelections=1 where we replace)
175          if (this.checked && selectedCount > maxSelections && maxSelections !== 1) {
176            this.checked = false;
177            selectedCount--;
178            return;
179          }
180          const selectedValues = selectedItems.map(i => i.value);
181          // **Disable unchecked checkboxes only when max selection is reached (and multi-select). For single (maxSelections=1), never disable so user can click another to switch.**
182          let shouldShowMessage = false;
183          items.forEach(i => {
184            if (!i.checked) {
185              const wasEnabled = !i.disabled;
186              const isDisabled = maxSelections > 1 && selectedCount >= maxSelections;
187              i.disabled = isDisabled;
188
189              // Show message when first disabling (transition from enabled to disabled)
190              if (wasEnabled && isDisabled && maxSelections > 1) {
191                shouldShowMessage = true;
192              }
193
194              // Add aria-disabled and title for screen reader context
195              if (isDisabled) {
196                i.setAttribute('aria-disabled', 'true');
197                const parentOption = i.closest('.selectify-available-one-option');
198                if (parentOption) {
199                  parentOption.setAttribute('aria-disabled', 'true');
200                  parentOption.setAttribute('title', Drupal.t('Maximum selections reached'));
201                }
202              } else {
203                i.removeAttribute('aria-disabled');
204                const parentOption = i.closest('.selectify-available-one-option');
205                if (parentOption) {
206                  parentOption.removeAttribute('aria-disabled');
207                  parentOption.removeAttribute('title');
208                }
209              }
210            }
211          });
212
213          // Show message when reaching max limit (only for maxSelections > 1)
214          if (shouldShowMessage) {
215            showMaxSelectionMessage();
216          }
217          // Sync hidden select field & update display
218          Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
219          Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
220        });
221      });
222      } else {
223      once('selectifyOptionLimit', items).forEach((item) => {
224        item.addEventListener('click', function(event) {
225          event.preventDefault();
226          event.stopPropagation();
227          let selectedItems = [...items].filter(i => i.classList.contains('s-selected'));
228          selectedCount = selectedItems.length;
229         if (!item.classList.contains('s-selected') && selectedCount >= maxSelections && maxSelections !== 1) {
230            showMaxSelectionMessage();
231            return;
232          }
233          // Determine the value and toggle the hidden select's state directly.
234          const value = item.getAttribute('data-value') || item.textContent.trim();
235          const option = nativeSelect.querySelector(`option[value="${value}"]`);
236          if (option) {
237            // Check if field is single-value by looking at data-multiple attribute
238            const isMultiple = nativeSelect.getAttribute('data-multiple') === 'true';
239
240            // For single-value fields (maxSelections=1), deselect all other options first
241            if (maxSelections === 1 && !isMultiple) {
242              nativeSelect.querySelectorAll('option').forEach(opt => {
243                if (opt !== option) {
244                  opt.selected = false;
245                }
246              });
247              option.selected = true;
248            } else {
249              // Normal toggle for multi-select
250              option.selected = !option.selected;
251            }
252          }
253          // After toggling, recalculate selected values (fresh from native select).
254          const selectedValues = Array.from(nativeSelect.options)
255            .filter(opt => opt.selected)
256            .map(opt => opt.value);
257          Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
258          Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
259          // Ensure the select field updates before dispatching the event.
260          setTimeout(() => {
261            nativeSelect.dispatchEvent(new Event('change', {
262              bubbles: true
263            }));
264            nativeSelect.dispatchEvent(new Event('input', {
265              bubbles: true
266            }));
267          }, 0);
268        });
269      });
270    }
271  };
272  /**
273   * Opens the dropdown while closing other open dropdowns.
274   *
275   * @param {HTMLElement} dropdown - The dropdown menu to open.
276   * @param {HTMLElement|null} triggerElement - The element that triggered the dropdown opening.
277   */
278  Drupal.selectify.openDropdown = (dropdown, triggerElement = null) => {
279    if (!dropdown) return;
280
281    // Close all other open dropdowns except the one being opened.
282    document.querySelectorAll('.selectify-available-display.toggled').forEach(otherDropdown => {
283      if (otherDropdown !== dropdown) {
284        Drupal.selectify.closeDropdown(otherDropdown);
285      }
286    });
287
288    if (typeof Drupal.selectify.slideDown === 'function') {
289      Drupal.selectify.slideDown(dropdown);
290    }
291
292    const combobox = dropdown.closest('[role="combobox"]');
293    if (combobox) {
294      combobox.setAttribute('aria-expanded', 'true');
295    }
296
297    const parentSelect = triggerElement?.closest('.selectify-select');
298    if (parentSelect) {
299      parentSelect.classList.add('selectify-opened');
300    }
301  };
302  /**
303   * Closes the dropdown using the existing `slideUp` function.
304   *
305   * @param {HTMLElement} dropdown - The dropdown menu to close.
306   * @param {HTMLElement|null} triggerElement - The element that triggered the dropdown closing.
307   */
308  Drupal.selectify.closeDropdown = (dropdown, triggerElement = null) => {
309    if (!dropdown) return;
310
311    if (typeof Drupal.selectify.slideUp === 'function') {
312      Drupal.selectify.slideUp(dropdown);
313    }
314
315    const combobox = dropdown.closest('[role="combobox"]');
316    if (combobox) {
317      combobox.setAttribute('aria-expanded', 'false');
318    }
319    const trigger = triggerElement || dropdown.previousElementSibling;
320    if (trigger && typeof trigger.focus === 'function') {
321      trigger.focus();
322    }
323
324    const parentSelect = dropdown.closest('.selectify-select');
325    if (parentSelect) {
326      parentSelect.classList.remove('selectify-opened');
327      // Remove direction classes from parent only
328      parentSelect.classList.remove('opens-up', 'opens-down');
329    }
330  };
331  /**
332   * Synchronizes the hidden <select> field with the selected values.
333   *
334   * @param {HTMLElement} nativeSelect - The hidden select element.
335   * @param {string[]} selectedValues - The selected option values.
336   */
337  Drupal.selectify.syncHiddenSelect = (nativeSelect, selectedValues) => {
338    if (!nativeSelect) return;
339    const noneOption = nativeSelect.querySelector('option[value="_none"]');
340    nativeSelect.querySelectorAll("option").forEach(option => {
341      if (option.value === '_none') {
342        // Only handle _none if it exists (don't invent it).
343        if (noneOption) {
344          option.selected = (selectedValues.length === 0);
345          option.toggleAttribute("selected", option.selected);
346          option.classList.toggle("s-selected", option.selected);
347        }
348      } else {
349        option.selected = selectedValues.includes(option.value);
350        option.toggleAttribute("selected", option.selected);
351        option.classList.toggle("s-selected", option.selected);
352      }
353      if (!option.classList.length) {
354        option.removeAttribute("class");
355      }
356    });
357    nativeSelect.dispatchEvent(new Event("change", {
358      bubbles: true
359    }));
360    nativeSelect.dispatchEvent(new Event("input", {
361      bubbles: true
362    }));
363  };
364  /**
365   * Adjusts dropdown height dynamically based on available space.
366   *
367   * @param {HTMLElement} selectifySelect - The selectify component.
368   * @param {HTMLElement} dropdownMenu - The dropdown menu to adjust.
369   */
370  Drupal.selectify.adjustDropdownHeight = (selectifySelect, dropdownMenu) => {
371    const windowHeight = window.innerHeight;
372    const dropdownRect = selectifySelect.getBoundingClientRect();
373    const spaceBelow = windowHeight - dropdownRect.bottom;
374    const spaceAbove = dropdownRect.top;
375    let maxHeight = Math.min(spaceBelow - 20, 300);
376    if (maxHeight < 150 && spaceAbove > spaceBelow) {
377      // Open upward - not enough space below
378      maxHeight = Math.min(spaceAbove - 20, 300);
379      dropdownMenu.style.top = 'auto';
380      dropdownMenu.style.bottom = '100%';
381      // Add direction classes to parent only
382      selectifySelect.classList.add('opens-up');
383      selectifySelect.classList.remove('opens-down');
384    } else {
385      // Open downward - default behavior
386      dropdownMenu.style.top = '100%';
387      dropdownMenu.style.bottom = 'auto';
388      // Add direction classes to parent only
389      selectifySelect.classList.add('opens-down');
390      selectifySelect.classList.remove('opens-up');
391    }
392    dropdownMenu.style.maxHeight = `${maxHeight}px`;
393    dropdownMenu.style.overflowY = 'auto';
394  };
395  /**
396   * Toggles option selection.
397   *
398   * @param {string} type - The type of selectify component.
399   * @param {HTMLElement} nativeSelect - The hidden <select> element.
400   * @param {HTMLElement} optionElement - The selected option element.
401   * @param {HTMLElement} selectedDisplay - The display area for selected options.
402   * @param {HTMLElement} clearAllButton - The button to clear all selected options.
403   */
404Drupal.selectify.toggleMultiSelectOption = function(type, nativeSelect, optionElement, selectedDisplay, clearAllButton) {
405    if (!type || !nativeSelect || !optionElement || !selectedDisplay) {
406      return;
407    }
408    const targetElement = optionElement.closest('.selectify-available-one-option');
409    if (!targetElement) return;
410    const value = targetElement.dataset.value;
411    const option = nativeSelect.querySelector(`option[value="${value}"]`);
412    const isExposedFilter = nativeSelect.closest('.views-exposed-form') !== null;
413    if (!option) {
414      return;
414 // Safety check — invalid option should do nothing.
415    }
416    const noneOption = nativeSelect.querySelector('option[value="_none"]');
417    if (value === '_none' && noneOption) {
418      if (isExposedFilter) {
419        // Views filters should ignore _none entirely.
420        return;
421      }
422      // Regular fields — treat _none like "clear all."
423      nativeSelect.querySelectorAll('option').forEach(opt => opt.selected = (opt.value === '_none'));
424      Drupal.selectify.syncHiddenSelect(nativeSelect, ['_none']);
425      Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
426      return;
427    }
428    // For single-value fields (maxSelections=1 or !multiple), deselect all other options first
429    const isMultiple = nativeSelect.hasAttribute('multiple');
430    if (!isMultiple) {
431      // Deselect all other options
432      nativeSelect.querySelectorAll('option').forEach(opt => {
433        if (opt !== option) {
434          opt.selected = false;
435        }
436      });
437      // Select this option
438      option.selected = true;
439    } else {
440      // Normal option toggle for multi-select
441      option.selected = !option.selected;
442    }
443    // If regular option is selected, unselect _none (if present).
444    if (option.selected && noneOption) {
445      noneOption.selected = false;
446    }
447    const selectedValues = Array.from(nativeSelect.options)
448      .filter(opt => opt.selected)
449      .map(opt => opt.value);
450    Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
451    Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
452  };
453  /**
454   * Removes a selected tag.
455   *
456   * @param {string} type - The type of selectify component.
457   * @param {HTMLElement} nativeSelect - The hidden <select> element.
458   * @param {HTMLElement} tagElement - The tag element to remove.
459   * @param {HTMLElement} selectedDisplay - The display area for selected options.
460   * @param {HTMLElement} clearAllButton - The button to clear all selected options.
461   */
462  Drupal.selectify.removeTag = (type, nativeSelect, tagElement, selectedDisplay, clearAllButton) => {
463    if (!tagElement || !nativeSelect || !selectedDisplay) return;
464
465    const value = tagElement.dataset.value;
466    if (!value) return;
467    const option = nativeSelect.querySelector(`option[value="${value}"]`);
468    if (option) {
469      option.selected = false;
470    }
471    const wrapper = selectedDisplay.closest('.selectify-select');
472    const dropdownMenu = wrapper ? wrapper.querySelector('.selectify-available-display') : null;
473    if (!dropdownMenu) return;
474    const optionElement = dropdownMenu.querySelector(`.selectify-available-one-option[data-value="${value}"]`);
475    if (optionElement) {
476      optionElement.classList.remove('s-selected', 's-hidden'); // Remove both classes correctly
477    }
478    // Sync the hidden select field.
479    const selectedValues = Array.from(nativeSelect.options)
480      .filter(opt => opt.selected)
481      .map(opt => opt.value);
482    // **Reinsert _none if no selections are left (only if field is not required)**
483    if (selectedValues.length === 0 && nativeSelect.hasAttribute("data-has-none")) {
484      const noneOption = nativeSelect.querySelector('option[value="_none"]');
485      if (noneOption) {
486        noneOption.selected = true;
487      }
488    }
489    Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
490    Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
491    tagElement.remove();
492  };
493  /**
494   * Retrieves selected values from a hidden `<select>`.
495   *
496   * @param {HTMLElement} nativeSelect - The hidden <select> element.
497   * @returns {string[]} - An array of selected option values.
498   */
499  Drupal.selectify.getSelectedValues = (nativeSelect) => {
500    let selectedValues = Array.from(nativeSelect.options)
501      .filter(option => option.selected)
502      .map(option => option.value)
503      .filter(value => value !== '_none');
504    // **Ensure _none is reinserted when no other values are selected**
505    if (selectedValues.length === 0 && nativeSelect.dataset.hasNone === "true") {
506      const noneOption = nativeSelect.querySelector('option[value="_none"]');
507      if (noneOption) {
508        noneOption.selected = true;
509        selectedValues = ['_none'];
510      }
511    }
512    return selectedValues;
513  };
514  /**
515   * For "Clear All": single-select with an "All" option should reset to "All", not empty.
516   *
517   * @param {HTMLSelectElement} nativeSelect - The hidden <select> element.
518   * @param {HTMLElement} selectWidget - The Selectify widget wrapper (for data-multiple).
519   * @returns {string[]} [] to deselect all, or ['All'] to select the "All" option.
520   */
521  Drupal.selectify.getClearTargetValues = (nativeSelect, selectWidget) => {
522    const isMultiple = selectWidget && selectWidget.getAttribute('data-multiple') === 'true';
523    if (isMultiple) {
524      return [];
525    }
526    const hasAll = Array.from(nativeSelect.options).some(opt => opt.value === 'All');
527    return hasAll ? ['All'] : [];
528  };
529  /**
530   * Close any open dropdown when clicking outside.
531   * Bound at module level (IIFE), so it only runs once per page load.
532   */
533  document.addEventListener('click', (event) => {
534    if (!event.target.closest('.selectify-available-display') && !event.target.closest('.selectify-selected-display')) {
535      document.querySelectorAll('.selectify-available-display.toggled').forEach(dropdown => {
536        Drupal.selectify.closeDropdown(dropdown);
537      });
538    }
539  });
540})(Drupal, drupalSettings);

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.