bootstrap_five_layouts-1.0.x-dev/js/behaviour.multiselect.js

js/behaviour.multiselect.js
/**
 * @file
 * Bootstrap Five Layouts multiselect js
 *
 * Provides enhanced multiselect UX with search functionality and optgroup constraints
 */

(function (Drupal) {
  'use strict';

  /**
   * Multiselect widget class
   */
  class MultiselectWidget {
    // Static array to track all instances
    static instances = [];

    /**
     * Generates a UUID v4 (Universally Unique Identifier)
     * Uses the Web Crypto API for better randomness when available
     * @return {string} A UUID v4 string
     */
    static generateUuid() {
      if (typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues) {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
          const r = window.crypto.getRandomValues(new Uint8Array(1))[0];
          const v = c === 'x' ? r : (r & 0x3 | 0x8);
          return v.toString(16);
        });
      } else {
        // Fallback to Math.random for environments without crypto API
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
          const r = Math.random() * 16 | 0;
          const v = c === 'x' ? r : (r & 0x3 | 0x8);
          return v.toString(16);
        });
      }
    }

    constructor(selectElement) {
      this.select = selectElement;
      this.wrapper = null;
      this.display = null;
      this.dropdown = null;
      this.optionsContainer = null;
      this.isOpen = false;
      this.selectedValues = new Set();
      this.singleGroupMode = this.select.hasAttribute('data-multiselect-single-group');
      this.multipleMode = this.select.hasAttribute('data-multiselect-multiple') || this.select.hasAttribute('multiple');
      this.singleSelectMode = !this.multipleMode && !this.singleGroupMode;
      this.size = Math.max(3, parseInt(this.select.getAttribute('size')) || 12);
      this.mutationObserver = null;

      // Register this instance in the global registry
      MultiselectWidget.instances.push(this);

      this.init();
    }

    init() {
      this.createWrapper();
      this.createDisplay();
      this.createDropdown();
      this.attachEvents();

      // Watch for changes to the original select element
      this.setupMutationObserver();

      // Initialize selected values from original select
      this.initializeSelectedValues();
      this.updateDisplay();
      this.updateOriginalSelect();
    }

    initializeSelectedValues() {
      // Get initially selected options from original select
      const selectedOptions = this.select.querySelectorAll('option:checked, option[selected]');

      if (this.singleSelectMode && selectedOptions.length > 1) {
        // For single-select mode, only keep the first selected option
        this.selectedValues.add(selectedOptions[0].value);
      } else {
        selectedOptions.forEach(option => {
          this.selectedValues.add(option.value);
        });
      }

      // Update button states after initializing selections
      this.updateUnselectButtonStates();
    }

    createWrapper() {
      // Create the main wrapper
      this.wrapper = document.createElement('div');
      this.wrapper.className = 'multiselect-wrapper';

      // Hide the original select and insert wrapper after it
      this.select.style.display = 'none';
      this.select.parentNode.insertBefore(this.wrapper, this.select.nextSibling);

      // Move the select inside the wrapper
      this.wrapper.appendChild(this.select);
    }

    createDisplay() {
      this.display = document.createElement('div');
      this.display.className = 'multiselect-display';
      this.display.setAttribute('tabindex', '0');
      this.display.setAttribute('role', 'combobox');
      this.display.setAttribute('aria-expanded', 'false');
      this.display.setAttribute('aria-haspopup', 'listbox');

      // Add hamburger menu indicator (first child)
      const arrow = document.createElement('button');
      arrow.className = 'multiselect-arrow';
      arrow.type = 'button';
      arrow.setAttribute('aria-label', Drupal.t('Toggle dropdown'));
      arrow.setAttribute('aria-expanded', 'false');
      arrow.innerHTML = `
        <span class="hamburger-line"></span>
        <span class="hamburger-line"></span>
        <span class="hamburger-line"></span>
      `;

      this.display.appendChild(arrow);
      this.wrapper.appendChild(this.display);
    }

    createDropdown() {
      this.dropdown = document.createElement('div');
      this.dropdown.className = 'multiselect-dropdown';
      this.dropdown.id = this.select.id ? `multiselect-parent-${this.select.id}` : `multiselect-parent-${MultiselectWidget.generateUuid()}`;
      this.dropdown.setAttribute('role', 'listbox');
      this.dropdown.setAttribute('aria-multiselectable', this.multipleMode ? 'true' : 'false');

      // Create options container
      this.optionsContainer = document.createElement('dl');
      this.optionsContainer.className = 'multiselect-options';
      this.optionsContainer.style.maxHeight = this.size  + 'rem'; // Approximate height per option

      this.dropdown.appendChild(this.optionsContainer);
      this.wrapper.appendChild(this.dropdown);

      this.populateOptions();
    }

    populateOptions() {
      this.optionsContainer.innerHTML = '';
      const optgroups = {};
      let hasOptgroups = false;

      // Group options by optgroup
      Array.from(this.select.options).forEach(option => {
        if (option.parentNode.tagName === 'OPTGROUP') {
          hasOptgroups = true;
          const optgroupLabel = option.parentNode.label;
          if (!optgroups[optgroupLabel]) {
            optgroups[optgroupLabel] = [];
          }
          optgroups[optgroupLabel].push(option);
        } else {
          if (!optgroups['']) {
            optgroups[''] = [];
          }
          optgroups[''].push(option);
        }
      });

      // Create option elements
      Object.keys(optgroups).forEach(optgroupLabel => {
        if (optgroups[optgroupLabel].length > 0) {
          if (hasOptgroups && optgroupLabel) {
            // Create optgroup label
            const optgroupDiv = document.createElement('dt');
            optgroupDiv.className = 'multiselect-optgroup';
            optgroupDiv.setAttribute('aria-label', Drupal.t('Option group: @label', { '@label': optgroupLabel }));

            const label = document.createElement('div');
            label.className = 'multiselect-optgroup-label';
            label.textContent = optgroupLabel;
            label.setAttribute('role', 'presentation'); // The label itself doesn't need a specific role

            optgroupDiv.appendChild(label);

            // Add unselect button inside optgroup for singleGroupMode
            if (this.singleGroupMode) {
              const unselectDiv = document.createElement('div');
              unselectDiv.className = 'multiselect-unselect';

              const unselectButton = document.createElement('button');
              unselectButton.type = 'button';
              unselectButton.className = 'multiselect-unselect-btn';
              unselectButton.textContent = Drupal.t('Unselect');
              unselectButton.setAttribute('aria-label', Drupal.t('Clear any selections in @group', { '@group': optgroupLabel }));

              unselectButton.addEventListener('click', (e) => {
                e.stopPropagation();
                this.clearRadioGroup(`multiselect-${optgroupLabel}`);
              });

              unselectDiv.appendChild(unselectButton);
              optgroupDiv.appendChild(unselectDiv);

              // Initially set the correct disabled state
              const groupName = `multiselect-${optgroupLabel}`;
              unselectButton.disabled = !this.hasSelectedItemsInGroup(groupName);
            }

            this.optionsContainer.appendChild(optgroupDiv);
          }

          // Add unselect button for singleSelectMode (as separate element since no optgroup)
          if (this.singleSelectMode) {
            const unselectDiv = document.createElement('div');
            unselectDiv.className = 'multiselect-unselect';

            const unselectButton = document.createElement('button');
            unselectButton.type = 'button';
            unselectButton.className = 'multiselect-unselect-btn';
            unselectButton.textContent = Drupal.t('Clear selection');
            unselectButton.setAttribute('aria-label', Drupal.t('Clear all selections'));

            unselectButton.addEventListener('click', (e) => {
              e.stopPropagation();
              this.clearRadioGroup('multiselect-single');
            });

            unselectDiv.appendChild(unselectButton);
            this.optionsContainer.appendChild(unselectDiv);

            // Initially set the correct disabled state
            unselectButton.disabled = !this.hasSelectedItemsInGroup('multiselect-single');
          }

          // Create options
          optgroups[optgroupLabel].forEach(option => {
            const optionDiv = document.createElement('dd');
            optionDiv.className = 'multiselect-option';
            optionDiv.setAttribute('data-value', option.value);
            optionDiv.setAttribute('aria-selected', this.selectedValues.has(option.value) ? 'true' : 'false');

            const input = document.createElement('input');
            // Use radio buttons for single-select modes, checkboxes for multiple mode
            input.type = (this.singleGroupMode || this.singleSelectMode) ? 'radio' : 'checkbox';
            if (this.singleGroupMode) {
              // Single group mode: radio buttons grouped by optgroup
              input.name = `multiselect-${optgroupLabel || 'default'}`;
            } else if (this.singleSelectMode) {
              // Single select mode: all radio buttons in same group
              input.name = 'multiselect-single';
            } else {
              // Multiple mode: checkboxes don't need a name since we're handling clicks manually
              input.name = '';
            }
            // Set checked state based on both our tracking and original select state
            // This ensures we don't lose selections when options are repopulated
            input.checked = this.selectedValues.has(option.value) || option.selected;

            // If option is selected but not in our tracking, add it
            if (option.selected && !this.selectedValues.has(option.value)) {
              this.selectedValues.add(option.value);
            }

            input.disabled = option.disabled;

            // Handle direct input clicks for checkbox mode
            if (this.multipleMode && input.type === 'checkbox') {
              input.addEventListener('click', (e) => {
                e.stopPropagation();
                if (!option.disabled) {
                  this.updateSelection(option.value, input.checked);
                }
              });
            }
//  todo! get original label classes (@default theme styling should carry)
            const label = document.createElement('label');
            label.textContent = option.text;

            // Sanitize option value for use in ID (replace spaces with dashes)
            const sanitizedValue = option.value.toString().replace(/\s+/g, '-');
            label.setAttribute('for', this.select.id ? `multiselect-item-${this.select.id}-${sanitizedValue}` : `multiselect-item-${sanitizedValue}-${MultiselectWidget.generateUuid()}`);
            // Make easy to trace org value.
            label.setAttribute('value', option.value);

            // Set input id for label association
            input.id = label.getAttribute('for');

            optionDiv.appendChild(input);
            optionDiv.appendChild(label);

            // Handle clicks on the label for checkbox mode
            if (this.multipleMode && input.type === 'checkbox') {
              label.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                if (!option.disabled) {
                  input.checked = !input.checked;
                  this.updateSelection(option.value, input.checked);
                }
              });
            }

            // Handle clicks on the option div for radio buttons and single-select modes
            if (!this.multipleMode) {
              optionDiv.addEventListener('click', (e) => {
                e.stopPropagation();
                if (!option.disabled) {
                  this.toggleOption(option.value, optgroupLabel, input);
                }
              });
            }

            // Handle direct input changes (for radio buttons and accessibility)
            input.addEventListener('change', () => {
              if (this.singleGroupMode || this.singleSelectMode) {
                // For radio buttons, update state immediately
                // Use setTimeout to ensure all radio button changes are processed
                setTimeout(() => {
                  this.updateSelectionFromDOM();
                }, 0);
              }
            });

            this.optionsContainer.appendChild(optionDiv);
          });
        }
      });

      // Update display to reflect any changes in selections
      this.updateDisplay();
    }

    toggleOption(value, optgroupLabel, input) {
      if (input.disabled) return;

      if (this.singleGroupMode || this.singleSelectMode) {
        // Single-select modes - radio buttons are handled automatically by browser
        if (!input.checked) {
          input.checked = true; // Only check if not already checked
        }
        // For single-select mode, directly update the selected value since only one can be selected
        if (this.singleSelectMode) {
          this.selectedValues.clear();
          this.selectedValues.add(value);
          this.updateDisplay();
          this.updateOriginalSelect();

          // Update button states after selection changes
          this.updateUnselectButtonStates();

          // Trigger change event
          const event = new Event('change', { bubbles: true });
          this.select.dispatchEvent(event);
        } else {
          // For single-group mode, use the normal update process
          setTimeout(() => {
            this.updateSelectionFromDOM();
          }, 10);
        }
      } else {
        // Multiple selection mode - toggle checkbox
        input.checked = !input.checked;
        this.updateSelection(value, input.checked);
      }
    }


    updateSelectionFromDOM() {
      // First, reset all aria-selected states
      const allOptions = this.optionsContainer.querySelectorAll('.multiselect-option');
      allOptions.forEach(optionDiv => {
        optionDiv.setAttribute('aria-selected', 'false');
      });

      // Clear current selection state
      this.selectedValues.clear();

      // Get all checked inputs and update state
      const inputType = (this.singleGroupMode || this.singleSelectMode) ? 'radio' : 'checkbox';
      const checkedInputs = this.optionsContainer.querySelectorAll(`input[type="${inputType}"]:checked`);

      checkedInputs.forEach(input => {
        const optionDiv = input.closest('.multiselect-option');
        const value = optionDiv.getAttribute('data-value');
        this.selectedValues.add(value);

        // Update aria-selected state
        optionDiv.setAttribute('aria-selected', 'true');

        // Update original select option
        const option = this.select.querySelector(`option[value="${value}"]`);
        if (option) {
          option.selected = true;
        }
      });

      // Update display and original select
      this.updateDisplay();
      this.updateOriginalSelect();

      // Update button states after selection changes
      this.updateUnselectButtonStates();

      // Trigger change event
      const event = new Event('change', { bubbles: true });
      this.select.dispatchEvent(event);
    }

    updateSelection(value, selected) {
      const option = this.select.querySelector(`option[value="${value}"]`);

      if (selected) {
        this.selectedValues.add(value);
        option.selected = true;
      } else {
        this.selectedValues.delete(value);
        option.selected = false;
      }

      this.updateDisplay();
      this.updateOriginalSelect();

      // Update button states after selection changes
      this.updateUnselectButtonStates();

      // Trigger change event
      const event = new Event('change', { bubbles: true });
      this.select.dispatchEvent(event);
    }

    /**
     * Creates a tag element for a selected option
     * @param {string} value - The option value
     * @return {HTMLElement|null} The tag element or null if option not found
     */
    createTag(value) {
      const option = this.select.querySelector(`option[value="${value}"]`);
      if (!option) {
        return null;
      }

      const tag = document.createElement('div');
      tag.className = 'multiselect-tag';
      tag.setAttribute('role', 'option');
      tag.setAttribute('aria-selected', 'true');
      tag.setAttribute('aria-label', Drupal.t('Selected: @option', { '@option': option.text }));

      const text = document.createElement('span');
      text.textContent = option.text;

      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'multiselect-tag-remove';
      remove.innerHTML = '×';
      remove.setAttribute('aria-label', Drupal.t('Unselect @option', { '@option': option.text }));
      remove.setAttribute('title', Drupal.t('Unselect'));

      remove.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        // console.log('Remove button clicked for value:', value);
        this.removeSelection(value);
      });

      // Prevent tag clicks from triggering dropdown toggle (but allow remove button clicks)
      tag.addEventListener('click', (e) => {
        // Only stop propagation if it's not the remove button being clicked
        if (!e.target.classList.contains('multiselect-tag-remove')) {
          e.stopPropagation();
        }
      });

      // Prevent text span clicks from triggering dropdown toggle
      text.addEventListener('click', (e) => {
        e.stopPropagation();
      });

      tag.appendChild(text);
      tag.appendChild(remove);
      return tag;
    }

    updateDisplay() {
      // console.log('Updating display. Current selected values:', Array.from(this.selectedValues));

      // Preserve the hamburger menu while updating content
      const arrow = this.display.querySelector('.multiselect-arrow');

      // Clear only the content, not the hamburger menu
      Array.from(this.display.children).forEach(child => {
        if (child !== arrow) {
          this.display.removeChild(child);
        }
      });

      if (this.selectedValues.size === 0) {
        const placeholder = document.createElement('span');
        placeholder.className = 'multiselect-placeholder';
        // Check if select has placeholder attribute and use its value
        const hasPlaceholder = this.select.hasAttribute('placeholder');
        const placeholderText = hasPlaceholder ? this.select.getAttribute('placeholder') : Drupal.t('Select options…');
        placeholder.textContent = placeholderText;

        // Add ARIA attributes for better accessibility
        placeholder.setAttribute('role', 'status');
        placeholder.setAttribute('aria-label',
          Drupal.t('No options selected. Click to open dropdown and select options.')
        );

        // Insert after hamburger (hamburger should be first)
        if (arrow && arrow.nextSibling) {
          this.display.insertBefore(placeholder, arrow.nextSibling);
        } else {
          this.display.appendChild(placeholder);
        }
      } else if (this.singleSelectMode) {
        // Single-select mode - show selected option as a single tag (only one selection possible)
        const value = this.selectedValues.values().next().value;
        const tag = this.createTag(value);
        if (tag) {
          // Insert after hamburger (hamburger should be first)
          if (arrow && arrow.nextSibling) {
            this.display.insertBefore(tag, arrow.nextSibling);
          } else {
            this.display.appendChild(tag);
          }
        }
      } else {
        // Multiple selection modes (including single group) - show all selected options as tags
        this.selectedValues.forEach((value, index) => {
          const tag = this.createTag(value);
          if (tag) {
            // Insert after hamburger (hamburger should be first)
            if (arrow && arrow.nextSibling && index === 0) {
              this.display.insertBefore(tag, arrow.nextSibling);
            } else {
              this.display.appendChild(tag);
            }
          }
        });
      }

      // Ensure hamburger menu is at the beginning (it should always be the first child)
      if (arrow && this.display.firstChild !== arrow) {
        this.display.insertBefore(arrow, this.display.firstChild);
      }

      // Update hamburger menu aria-expanded state
      if (arrow) {
        arrow.setAttribute('aria-expanded', this.isOpen ? 'true' : 'false');
      }
      // Add/remove open class for styling
      this.wrapper.classList.toggle('open', this.isOpen);
    }

    removeSelection(value) {
      // console.log('Removing selection for value:', value, 'Current selected values:', Array.from(this.selectedValues));

      // Temporarily disconnect the mutation observer to prevent interference
      if (this.mutationObserver) {
        this.mutationObserver.disconnect();
      }

      // Remove from selected values first
      this.selectedValues.delete(value);

      // Find and uncheck the specific input for this value
      const optionDivs = this.optionsContainer.querySelectorAll('.multiselect-option');
      let foundOptionDiv = null;
      for (const div of optionDivs) {
        if (div.getAttribute('data-value') === value) {
          foundOptionDiv = div;
          break;
        }
      }

      if (foundOptionDiv) {
        const inputType = (this.singleGroupMode || this.singleSelectMode) ? 'radio' : 'checkbox';
        const input = foundOptionDiv.querySelector(`input[type="${inputType}"]`);
        if (input && input.checked) {
          input.checked = false;
        }
        // Update aria-selected state
        foundOptionDiv.setAttribute('aria-selected', 'false');
      }

      // Also update the original select element directly
      const options = this.select.options;
      let foundOption = null;
      for (let i = 0; i < options.length; i++) {
        if (options[i].value === value) {
          foundOption = options[i];
          break;
        }
      }

      if (foundOption) {
        // console.log('Found option in original select:', foundOption.value, 'setting selected to false');
        // console.log('Before setting:', foundOption.selected);
        foundOption.selected = false;
        // console.log('After setting:', foundOption.selected);
      } else {
        // console.log('Could not find option in original select for value:', value);
        // If we can't find the option directly, try to update all options based on selectedValues
        Array.from(this.select.options).forEach(option => {
          if (option.value === value) {
            // console.log('Found option by iterating:', option.value, 'before:', option.selected);
            option.selected = false;
            // console.log('After setting by iteration:', option.selected);
          }
        });
      }

      // Debug only:  Verify the select element state
      // console.log('Select element options after update:');
      // Array.from(this.select.options).forEach(option => {
      //   console.log(`  ${option.value}: selected=${option.selected}`);
      // });

      // Update original select to ensure consistency (this will handle any remaining options)
      this.updateOriginalSelect();

      // Update display to reflect the new selection state
      this.updateDisplay();

      // console.log('After updateDisplay. Selected values:', Array.from(this.selectedValues), 'Display children:', this.display.children.length);

      // Reconnect the mutation observer
      if (this.mutationObserver) {
        this.setupMutationObserver();
      }

      // Trigger change event
      const event = new Event('change', { bubbles: true });
      this.select.dispatchEvent(event);
    }

    /**
     * Checks if a radio group has any selected items
     * @param {string} groupName - The name of the radio group
     * @return {boolean} True if any items in the group are selected
     */
    hasSelectedItemsInGroup(groupName) {
      if (this.singleSelectMode && groupName === 'multiselect-single') {
        return this.selectedValues.size > 0;
      } else if (this.singleGroupMode) {
        const radioInputs = this.optionsContainer.querySelectorAll(`input[type="radio"][name="${groupName}"]`);
        return Array.from(radioInputs).some(input => input.checked);
      }
      return false;
    }

    /**
     * Updates the disabled state of unselect buttons based on current selections
     */
    updateUnselectButtonStates() {
      if (this.singleSelectMode) {
        const button = this.optionsContainer.querySelector('.multiselect-unselect-btn');
        if (button) {
          button.disabled = !this.hasSelectedItemsInGroup('multiselect-single');
        }
      } else if (this.singleGroupMode) {
        // Update all unselect buttons for each optgroup
        const optgroups = this.optionsContainer.querySelectorAll('.multiselect-optgroup');
        optgroups.forEach(optgroup => {
          const unselectDiv = optgroup.querySelector('.multiselect-unselect');
          if (unselectDiv) {
            const button = unselectDiv.querySelector('.multiselect-unselect-btn');
            if (button) {
              // Extract group name from button's click handler or data attribute
              const groupName = this.getGroupNameForButton(button);
              button.disabled = !this.hasSelectedItemsInGroup(groupName);
            }
          }
        });
      }
    }

    /**
     * Gets the group name for a given unselect button
     * @param {HTMLElement} button - The unselect button element
     * @return {string} The group name
     */
    getGroupNameForButton(button) {
      // For singleSelectMode, it's always 'multiselect-single'
      if (this.singleSelectMode) {
        return 'multiselect-single';
      }

      // For singleGroupMode, we need to find the group name from the button's context
      // Look for the optgroup label to determine the group
      const optgroup = button.closest('.multiselect-optgroup');
      if (optgroup) {
        const label = optgroup.querySelector('.multiselect-optgroup-label');
        if (label && label.textContent) {
          return `multiselect-${label.textContent}`;
        }
      }

      return 'multiselect-default';
    }

    clearRadioGroup(groupName) {
      // Clear our internal state for this group
      const radioInputs = this.optionsContainer.querySelectorAll(`input[type="radio"][name="${groupName}"]`);
      radioInputs.forEach(input => {
        const optionDiv = input.closest('.multiselect-option');
        const value = optionDiv.getAttribute('data-value');
        this.selectedValues.delete(value);
        input.checked = false;
        optionDiv.setAttribute('aria-selected', 'false');
      });

      // Update original select and display
      this.updateOriginalSelect();
      this.updateDisplay();

      // Update button states after clearing
      this.updateUnselectButtonStates();

      // Trigger change event
      const event = new Event('change', { bubbles: true });
      this.select.dispatchEvent(event);
    }

    clearAllSelections() {
      // Clear our internal state first
      this.selectedValues.clear();

      // Uncheck all inputs and reset aria-selected states
      const inputType = (this.singleGroupMode || this.singleSelectMode) ? 'radio' : 'checkbox';
      const allOptions = this.optionsContainer.querySelectorAll('.multiselect-option');
      allOptions.forEach(optionDiv => {
        optionDiv.setAttribute('aria-selected', 'false');
        const input = optionDiv.querySelector(`input[type="${inputType}"]`);
        if (input) {
          input.checked = false;
        }
      });

      // Update original select and display
      this.updateOriginalSelect();
      this.updateDisplay();

      // Trigger change event
      const event = new Event('change', { bubbles: true });
      this.select.dispatchEvent(event);
    }

    setupMutationObserver() {
      // Watch for changes to the original select element (options added/removed, selection changes)
      if (typeof MutationObserver !== 'undefined') {
        this.mutationObserver = new MutationObserver((mutations) => {
          let needsRepopulation = false;

          mutations.forEach(mutation => {
            if (mutation.type === 'childList' && mutation.target === this.select) {
              // Options were added or removed
              needsRepopulation = true;
            }
          });

          if (needsRepopulation) {
            // Before repopulating, sync our state with the current original select
            // This ensures we don't lose any external changes
            this.syncWithOriginalSelect();
            // Repopulate options - this handles syncing selections properly
            this.populateOptions();
          } else {
            // For attribute changes (selection/disabled state), just sync the UI
            // The original select state is the source of truth for selections
            this.syncDisplayWithOriginalSelect();
          }
        });

        this.mutationObserver.observe(this.select, {
          childList: true,
          subtree: true,
          attributes: true,
          attributeFilter: ['selected', 'disabled']
        });
      }
    }

    syncWithOriginalSelect() {
      // Sync selected values with current state of original select
      const currentSelectedValues = new Set();
      Array.from(this.select.options).forEach(option => {
        if (option.selected) {
          currentSelectedValues.add(option.value);
        }
      });

      // Update our tracking to match
      this.selectedValues = currentSelectedValues;
    }

    syncDisplayWithOriginalSelect() {
      // Sync our UI state with the original select without modifying our internal state
      // This is used when the original select changes externally
      const currentSelectedValues = new Set();
      Array.from(this.select.options).forEach(option => {
        if (option.selected) {
          currentSelectedValues.add(option.value);
        }
      });

      // Update our tracking to match the original select
      this.selectedValues = currentSelectedValues;

      // Update the UI to reflect the new state
      this.updateDisplay();
    }

    updateOriginalSelect() {
      // console.log('Updating original select. Current selected values:', Array.from(this.selectedValues));

      // Update the original select to match our internal state
      // This ensures the original select stays in sync with our widget

      Array.from(this.select.options).forEach(option => {
        const shouldBeSelected = this.selectedValues.has(option.value);
        // console.log(`Option ${option.value}: shouldBeSelected=${shouldBeSelected}, currently selected=${option.selected}`);

        // Only change selection if it differs from current state
        // This avoids unnecessary DOM mutations and potential loops
        if (option.selected !== shouldBeSelected) {
          // console.log(`Updating option ${option.value} selected state to ${shouldBeSelected}`);
          option.selected = shouldBeSelected;
        }
      });
    }

    /**
     * Focuses the first selectable option in the dropdown
     */
    focusFirstOption() {
      if (!this.isOpen) return;

      // Find the first enabled option input
      const firstInput = this.optionsContainer.querySelector('input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled])');
      if (firstInput) {
        firstInput.focus();
      } else {
        // If no selectable options, focus back to display element
        this.display.focus();
      }
    }

    /**
     * Focuses the last selectable option in the dropdown
     */
    focusLastOption() {
      if (!this.isOpen) return;

      // Find the last enabled option input
      const inputs = this.optionsContainer.querySelectorAll('input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled])');
      if (inputs.length > 0) {
        inputs[inputs.length - 1].focus();
      } else {
        // If no selectable options, focus back to display element
        this.display.focus();
      }
    }

    /**
     * Gets the currently focused option input
     * @return {HTMLElement|null} The focused input element or null
     */
    getFocusedOption() {
      return this.optionsContainer.querySelector('input[type="radio"]:focus, input[type="checkbox"]:focus');
    }

    /**
     * Gets all selectable option inputs
     * @return {NodeList} List of selectable input elements
     */
    getSelectableOptions() {
      return this.optionsContainer.querySelectorAll('input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled])');
    }

    /**
     * Navigates to the next option in the dropdown
     */
    focusNextOption() {
      const selectableOptions = this.getSelectableOptions();
      const currentFocus = this.getFocusedOption();

      if (!currentFocus) {
        this.focusFirstOption();
        return;
      }

      const currentIndex = Array.from(selectableOptions).indexOf(currentFocus);
      if (currentIndex < selectableOptions.length - 1) {
        selectableOptions[currentIndex + 1].focus();
      } else if (selectableOptions.length > 0) {
        // Wrap to first option
        selectableOptions[0].focus();
      }
    }

    /**
     * Navigates to the previous option in the dropdown
     */
    focusPreviousOption() {
      const selectableOptions = this.getSelectableOptions();
      const currentFocus = this.getFocusedOption();

      if (!currentFocus) {
        this.focusFirstOption();
        return;
      }

      const currentIndex = Array.from(selectableOptions).indexOf(currentFocus);
      if (currentIndex > 0) {
        selectableOptions[currentIndex - 1].focus();
      } else if (selectableOptions.length > 0) {
        // Wrap to last option
        selectableOptions[selectableOptions.length - 1].focus();
      }
    }

    attachEvents() {
      // Arrow button click/toggle (handle first to prevent bubbling)
      const arrow = this.display.querySelector('.multiselect-arrow');
      if (arrow) {
        arrow.setAttribute('aria-controls', this.dropdown.id);
        arrow.addEventListener('click', (e) => {
          e.preventDefault();
          e.stopPropagation();
          this.toggleDropdown();
        });
      }

      // Display click/toggle (only if not clicking arrow button)
      this.display.addEventListener('click', (e) => {
        if (!e.target.classList.contains('multiselect-arrow')) {
          this.toggleDropdown();
        }
      });

      // Close on outside click
      document.addEventListener('click', (e) => {
        if (!this.wrapper.contains(e.target) && this.isOpen) {
          this.closeDropdown();
        }
      });

      // Keyboard navigation for display element (toggle dropdown)
      this.display.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          this.toggleDropdown();
        } else if (e.key === 'ArrowDown' && this.isOpen) {
          e.preventDefault();
          this.focusFirstOption();
        } else if (e.key === 'ArrowUp' && this.isOpen) {
          e.preventDefault();
          this.focusLastOption();
        }
      });

      // Comprehensive keyboard navigation for dropdown options
      this.optionsContainer.addEventListener('keydown', (e) => {
        if (!this.isOpen) return;

        switch (e.key) {
          case 'ArrowDown':
            e.preventDefault();
            this.focusNextOption();
            break;
          case 'ArrowUp':
            e.preventDefault();
            this.focusPreviousOption();
            break;
          case 'Enter':
          case ' ':
            e.preventDefault();
            const focusedInput = this.getFocusedOption();
            if (focusedInput) {
              const optionDiv = focusedInput.closest('.multiselect-option');
              const value = optionDiv.getAttribute('data-value');
              this.toggleOption(value, null, focusedInput);
            }
            break;
          case 'Escape':
            e.preventDefault();
            this.closeDropdown();
            this.display.focus();
            break;
          case 'Tab':
            // Implement focus trapping within the dropdown
            const selectableOptions = this.getSelectableOptions();
            if (selectableOptions.length === 0) {
              // No selectable options, allow normal tab behavior
              return;
            }

            if (e.shiftKey) {
              // Shift+Tab - go to previous option or wrap to last
              e.preventDefault();
              this.focusPreviousOption();
            } else {
              // Tab - go to next option or wrap to first
              e.preventDefault();
              this.focusNextOption();
            }
            break;
          case 'Home':
            e.preventDefault();
            this.focusFirstOption();
            break;
          case 'End':
            e.preventDefault();
            this.focusLastOption();
            break;
        }
      });

      // Prevent dropdown from closing when clicking inside
      this.dropdown.addEventListener('click', (e) => {
        e.stopPropagation();
      });

      // Handle focus management for option inputs
      this.optionsContainer.addEventListener('focusin', (e) => {
        if (e.target.matches('input[type="radio"], input[type="checkbox"]')) {
          // Remove focused class from all options first
          const allOptions = this.optionsContainer.querySelectorAll('.multiselect-option');
          allOptions.forEach(option => option.classList.remove('focused'));

          // Add focused class to the current option
          const optionDiv = e.target.closest('.multiselect-option');
          if (optionDiv) {
            optionDiv.classList.add('focused');
            const value = optionDiv.getAttribute('data-value');
            const isSelected = this.selectedValues.has(value);
            optionDiv.setAttribute('aria-selected', isSelected ? 'true' : 'false');
          }
        }
      });

      // Handle focus out to remove focused class and implement focus trapping
      this.optionsContainer.addEventListener('focusout', (e) => {
        if (e.target.matches('input[type="radio"], input[type="checkbox"]')) {
          const optionDiv = e.target.closest('.multiselect-option');
          if (optionDiv) {
            optionDiv.classList.remove('focused');
          }
        }
      });

      // Focus trap: ensure focus stays within dropdown when open
      this.dropdown.addEventListener('focusin', (e) => {
        if (this.isOpen && e.target === this.dropdown) {
          // Focus landed on the dropdown container itself, redirect to first option
          e.preventDefault();
          this.focusFirstOption();
        }
      });
    }

    toggleDropdown() {
      if (this.isOpen) {
        this.closeDropdown();
      } else {
        this.openDropdown();
      }
    }

    openDropdown() {
      // Close all other instances first
      MultiselectWidget.instances.forEach(instance => {
        if (instance !== this && instance.isOpen) {
          instance.closeDropdown();
        }
      });

      this.isOpen = true;
      this.dropdown.classList.add('open');
      this.display.setAttribute('aria-expanded', 'true');

      // Update arrow aria-expanded state and controls
      const arrow = this.display.querySelector('.multiselect-arrow');
      if (arrow) {
        arrow.setAttribute('aria-expanded', 'true');
        arrow.setAttribute('aria-controls', this.dropdown.id);
      }

      // Update wrapper class for styling
      this.wrapper.classList.add('open');

      // Focus the first selectable option for keyboard navigation
      this.focusFirstOption();

      // Focus the display for keyboard navigation only if it's the active element
      // or if no other element is focused (prevents focus jumping between instances)
      if (document.activeElement === this.display || document.activeElement === document.body) {
        this.display.focus();
      }
    }

    closeDropdown() {
      this.isOpen = false;
      this.dropdown.classList.remove('open');
      this.display.setAttribute('aria-expanded', 'false');

      // Update arrow aria-expanded state and controls
      const arrow = this.display.querySelector('.multiselect-arrow');
      if (arrow) {
        arrow.setAttribute('aria-expanded', 'false');
        arrow.setAttribute('aria-controls', this.dropdown.id);
      }

      // Update wrapper class for styling
      this.wrapper.classList.remove('open');

      // Return focus to display element when closing
      // This ensures proper focus management for accessibility
      if (document.activeElement && this.dropdown.contains(document.activeElement)) {
        this.display.focus();
      }
    }

    destroy() {
      // Remove this instance from the global registry
      const index = MultiselectWidget.instances.indexOf(this);
      if (index > -1) {
        MultiselectWidget.instances.splice(index, 1);
      }

      // Disconnect mutation observer
      if (this.mutationObserver) {
        this.mutationObserver.disconnect();
      }

      // Remove event listeners and clean up DOM
      if (this.wrapper && this.wrapper.parentNode) {
        this.wrapper.parentNode.removeChild(this.wrapper);
      }
    }

    // Static method to clean up orphaned instances
    static cleanup() {
      MultiselectWidget.instances = MultiselectWidget.instances.filter(instance => {
        // Check if the wrapper still exists in the DOM
        return instance.wrapper && document.body.contains(instance.wrapper);
      });
    }

  }

  /**
   * Drupal behavior for multiselect functionality
   */
  Drupal.behaviors.multiselect = {
    attach: function (context, settings) {
      // Clean up orphaned instances first
      MultiselectWidget.cleanup();

      // Process all contexts, including AJAX-loaded content
      const selects = context.querySelectorAll('select[data-multiselect-enhanced]');

      selects.forEach(select => {
        // Only enhance if not already processed and not disabled
        if (!select.closest('.multiselect-wrapper') && !select.disabled) {
          new MultiselectWidget(select);
        }
      });
    }
  };

  /**
   * Helper function to mark selects for multiselect enhancement
   */
  Drupal.multiselect = {
    enhance: function(selector) {
      const elements = document.querySelectorAll(selector || 'select[multiple]');
      elements.forEach(element => {
        if (!element.hasAttribute('data-multiselect-enhanced')) {
          element.setAttribute('data-multiselect-enhanced', 'true');
          Drupal.behaviors.multiselect.attach(element);
        }
      });
    }
  };

})(Drupal);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc