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

js/behaviour.bsflPillBox.js
/**
 * @file
 * Bootstrap Five Layouts bsflPillBox
 *
 * Provides pill box functionality similar to Mantine's PillsInput component
 * Creates pills from space-separated input and applies special styling for Bootstrap 5 column classes
 */

(function (Drupal, drupalSettings) {
  'use strict';

  const BOOTSTRAP_RESTRICTED_CLASSES = [
    'container', 'container-sm', 'container-md', 'container-lg', 'container-xl', 'container-xxl', 'container-fluid',
  ];

  const BOOTSTRAP_SPECIAL_CLASSES = [
    'row',
  ];

  /**
   * Bootstrap 5 regular classes for special pill styling, not known/supplied by drupalSettings.bootstrap_five_layouts.classlist_map
   */
  const BOOTSTRAP_REGULAR_CLASSES = [
    // Basic columns
    'col', 'col-auto', 'col-1', 'col-2', 'col-3', 'col-4', 'col-5', 'col-6',
    'col-7', 'col-8', 'col-9', 'col-10', 'col-11', 'col-12',
    // Small breakpoint columns
    'col-sm', 'col-sm-auto', 'col-sm-1', 'col-sm-2', 'col-sm-3', 'col-sm-4', 'col-sm-5', 'col-sm-6',
    'col-sm-7', 'col-sm-8', 'col-sm-9', 'col-sm-10', 'col-sm-11', 'col-sm-12',
    // Medium breakpoint columns
    'col-md', 'col-md-auto', 'col-md-1', 'col-md-2', 'col-md-3', 'col-md-4', 'col-md-5', 'col-md-6',
    'col-md-7', 'col-md-8', 'col-md-9', 'col-md-10', 'col-md-11', 'col-md-12',
    // Large breakpoint columns
    'col-lg', 'col-lg-auto', 'col-lg-1', 'col-lg-2', 'col-lg-3', 'col-lg-4', 'col-lg-5', 'col-lg-6',
    'col-lg-7', 'col-lg-8', 'col-lg-9', 'col-lg-10', 'col-lg-11', 'col-lg-12',
    // Extra large breakpoint columns
    'col-xl', 'col-xl-auto', 'col-xl-1', 'col-xl-2', 'col-xl-3', 'col-xl-4', 'col-xl-5', 'col-xl-6',
    'col-xl-7', 'col-xl-8', 'col-xl-9', 'col-xl-10', 'col-xl-11', 'col-xl-12',
    // Extra extra large breakpoint columns
    'col-xxl', 'col-xxl-auto', 'col-xxl-1', 'col-xxl-2', 'col-xxl-3', 'col-xxl-4', 'col-xxl-5', 'col-xxl-6',
    'col-xxl-7', 'col-xxl-8', 'col-xxl-9', 'col-xxl-10', 'col-xxl-11', 'col-xxl-12',
    // Row columns
    'row-cols-auto', 'row-cols-1', 'row-cols-2', 'row-cols-3', 'row-cols-4', 'row-cols-5', 'row-cols-6',
    'row-cols-sm-auto', 'row-cols-sm-1', 'row-cols-sm-2', 'row-cols-sm-3', 'row-cols-sm-4', 'row-cols-sm-5', 'row-cols-sm-6',
    'row-cols-md-auto', 'row-cols-md-1', 'row-cols-md-2', 'row-cols-md-3', 'row-cols-md-4', 'row-cols-md-5', 'row-cols-md-6',
    'row-cols-lg-auto', 'row-cols-lg-1', 'row-cols-lg-2', 'row-cols-lg-3', 'row-cols-lg-4', 'row-cols-lg-5', 'row-cols-lg-6',
    'row-cols-xl-auto', 'row-cols-xl-1', 'row-cols-xl-2', 'row-cols-xl-3', 'row-cols-xl-4', 'row-cols-xl-5', 'row-cols-xl-6',
    'row-cols-xxl-auto', 'row-cols-xxl-1', 'row-cols-xxl-2', 'row-cols-xxl-3', 'row-cols-xxl-4', 'row-cols-xxl-5', 'row-cols-xxl-6',
    // order classes
    'order-1', 'order-2', 'order-3', 'order-4', 'order-5', 'order-6',
    'order-sm-1', 'order-sm-2', 'order-sm-3', 'order-sm-4', 'order-sm-5', 'order-sm-6',
    'order-md-1', 'order-md-2', 'order-md-3', 'order-md-4', 'order-md-5', 'order-md-6',
    'order-lg-1', 'order-lg-2', 'order-lg-3', 'order-lg-4', 'order-lg-5', 'order-lg-6',
    'order-xl-1', 'order-xl-2', 'order-xl-3', 'order-xl-4', 'order-xl-5', 'order-xl-6',
    'order-xxl-1', 'order-xxl-2', 'order-xxl-3', 'order-xxl-4', 'order-xxl-5', 'order-xxl-6',
    'order-first', 'order-last',
    'order-sm-first', 'order-sm-last',
    'order-md-first', 'order-md-last',
    'order-lg-first', 'order-lg-last',
    'order-xl-first', 'order-xl-last',
    'order-xxl-first', 'order-xxl-last',
  ];

  /**
   * PillBox widget class
   */
  class PillBoxWidget {
    // 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);
        });
      }
    }

    /**
     * Initializes Sortable.js for drag-and-drop functionality
     */
    initializeSortable() {
      if (typeof Sortable !== 'undefined' && this.pillsContainer) {
        this.sortable = new Sortable(this.pillsContainer, {
          animation: 150,
          ghostClass: 'bsfl-pillbox-pill--ghost',
          chosenClass: 'bsfl-pillbox-pill--chosen',
          dragClass: 'bsfl-pillbox-pill--drag',
          handle: '.bsfl-pillbox-pill', // Only allow dragging on the pill itself, not the remove button
          onEnd: (evt) => {
            // Update the pills Set to reflect new order
            this.updatePillsFromDOM();
            this.updateOriginalInput();

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

            // console.log('Pill reordered via Sortable.js');
          }
        });
      }
    }

    /**
     * Updates the pills Set based on current DOM order
     */
    updatePillsFromDOM() {
      const pillElements = this.pillsContainer.querySelectorAll('.bsfl-pillbox-pill');
      const newPills = new Set();

      pillElements.forEach(pill => {
        const className = pill.querySelector('.bsfl-pillbox-pill-text').textContent;
        if (this.allowDuplicates || !newPills.has(className)) {
          newPills.add(className);
        }
      });

      this.pills = newPills;
    }

    constructor(inputElement) {
      this.input = inputElement;
      this.wrapper = null;
      this.pillsContainer = null;
      this.inputField = null;
      this.pills = new Set();
      this.allowDuplicates = this.input.hasAttribute('data-bsfl-pillbox-allow-duplicates');
      this.mutationObserver = null;
      this.sortable = null;

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

      this.init();
    }

    init() {
      this.createWrapper();
      this.createInputField();
      this.createPillsContainer();
      this.attachEvents();

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

      // Initialize pills from original input value
      this.initializePills();
      this.updateOriginalInput();

      // Initialize Sortable.js for drag-and-drop
      this.initializeSortable();
    }

    initializePills() {
      const initialValue = this.input.value.trim();
      if (initialValue) {
        const classes = initialValue.split(/\s+/).filter(cls => cls.length > 0);
        classes.forEach(cls => {
          if (this.allowDuplicates || !this.pills.has(cls)) {
            this.pills.add(cls);
          }
        });
      }
      this.updateDisplay();
    }

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

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

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

    createPillsContainer() {
      this.pillsContainer = document.createElement('div');
      this.pillsContainer.className = 'bsfl-pillbox-pills';
      this.pillsContainer.setAttribute('role', 'list');
      this.pillsContainer.setAttribute('aria-label', Drupal.t('Selected classes'));

      this.wrapper.appendChild(this.pillsContainer);
    }

    createInputField() {
      this.inputField = document.createElement('input');
      this.inputField.type = 'text';
      this.inputField.className = 'bsfl-pillbox-input';
      let placeholder = this.input.getAttribute('placeholder') || Drupal.t('Enter classes...');
      this.inputField.setAttribute('placeholder', placeholder);
      this.inputField.setAttribute('aria-label', Drupal.t('Enter classes'));
      // Carry over any classes from the original input so styling persists.
      const originalClassAttr = this.input.getAttribute('class');
      if (originalClassAttr) {
        originalClassAttr.split(/\s+/).forEach(cls => {
          if (cls && !this.inputField.classList.contains(cls)) {
            this.inputField.classList.add(cls);
          }
        });
      }

      // Copy only non-name/id attributes so the original hidden input remains
      // the single source of truth for form submission.
      const attributesToCopy = ['maxlength'];
      attributesToCopy.forEach(attr => {
        if (this.input.hasAttribute(attr)) {
          this.inputField.setAttribute(attr, this.input.getAttribute(attr));
        }
      });

      // Ensure the visible input does not conflict in submission
      // (keep name only on the hidden original input)
      this.inputField.removeAttribute('name');
// org name
      // Assign a unique ID to the visible input so a label can target it.
      const newId = 'bsfl-pillbox-' + PillBoxWidget.generateUuid();
      this.inputField.id = newId;

      // Create a new label that mirrors the original label text and associates
      // it to the new visible input for proper accessibility.
      const originalId = this.input.getAttribute('id');
      let originalLabelText = null;
      let originalLabelRef = null;
      if (originalId) {
        const originalLabel = document.querySelector('label[for="' + originalId + '"]');
        if (originalLabel) {
          originalLabelText = originalLabel.textContent;
          originalLabelRef = originalLabel;
          // Hide the original label visually while keeping it accessible.
          originalLabel.classList.add('visually-hidden');
          originalLabel.setAttribute('data-ife-id', originalId);

        }
      }

      // Append input first, then insert the label before it (so label appears above input).
      this.wrapper.appendChild(this.inputField);
      if (originalLabelText) {
        const labelEl = document.createElement('label');
        labelEl.className = 'bsfl-pillbox-label';
        labelEl.setAttribute('for', newId);
        labelEl.textContent = originalLabelText;
        if (originalLabelRef && originalLabelRef.parentNode) {
          originalLabelRef.parentNode.insertBefore(labelEl, originalLabelRef.nextSibling);
        }
        else {
          // Fallback: place inside wrapper above input if original label not found in DOM.
          this.wrapper.insertBefore(labelEl, this.inputField);
        }
      }
    }

    /**
     * Creates a pill element for a given class
     * @param {string} className - The class name
     * @return {HTMLElement} The pill element
     */
    createPill(className) {
      const pill = document.createElement('div');
      pill.className = 'bsfl-pillbox-pill';
      pill.setAttribute('role', 'listitem');
      pill.setAttribute('aria-label', Drupal.t('Class: @class', { '@class': className }));

      // Add special styling for Bootstrap column classes
      if (BOOTSTRAP_REGULAR_CLASSES.includes(className)) {
        pill.classList.add('bsfl-pillbox-pill--bootstrap');
      }

      const classListMap = (drupalSettings
        && drupalSettings.bootstrap_five_layouts
        && Array.isArray(drupalSettings.bootstrap_five_layouts.classlist_map))
        ? drupalSettings.bootstrap_five_layouts.classlist_map
        : [];

      if (classListMap.includes(className)) {
        pill.classList.add('bsfl-pillbox-pill--bootstrap');
        pill.classList.add('bsfl-pillbox-pill--classmap-dynamic-list');
      }
      // Check if the class is in the pillbox classes list (defensive for missing settings)
      const pillboxClasses = (drupalSettings
        && drupalSettings.bootstrap_five_layouts
        && Array.isArray(drupalSettings.bootstrap_five_layouts.pillbox_classes))
        ? drupalSettings.bootstrap_five_layouts.pillbox_classes
        : [];
      if (pillboxClasses.includes(className)) {
        pill.classList.add('bsfl-pillbox-pill--pillbox-custom');
      }

      // Check if the class is in the pillbox classes list
      if (BOOTSTRAP_SPECIAL_CLASSES.includes(className)) {
        pill.classList.add('bsfl-pillbox-pill--special');
      }

      if (BOOTSTRAP_RESTRICTED_CLASSES.includes(className)) {
        pill.classList.add('bsfl-pillbox-pill--restricted');
      }
      const text = document.createElement('span');
      text.className = 'bsfl-pillbox-pill-text';
      text.textContent = className;

      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'bsfl-pillbox-pill-remove';
      remove.innerHTML = '×';
      remove.setAttribute('aria-label', Drupal.t('Remove @class', { '@class': className }));
      remove.setAttribute('title', Drupal.t('Remove'));

      remove.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        this.removePill(className);
      });

      // Prevent pill clicks from focusing the input (but allow remove button clicks)
      pill.addEventListener('click', (e) => {
        if (!e.target.classList.contains('bsfl-pillbox-pill-remove')) {
          e.stopPropagation();
        }
      });

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

    /**
     * Parses input text and creates pills from space-separated classes
     * @param {string} text - The input text to parse
     */
    parseAndAddPills(text) {
      const classes = text.split(/\s+/).filter(cls => cls.length > 0);

      classes.forEach(cls => {
        if (this.allowDuplicates || !this.pills.has(cls)) {
          this.pills.add(cls);
        }
      });

      this.updateDisplay();
      this.updateOriginalInput();

      // Trigger change event so external listeners (e.g., test reports) update
      const event = new Event('change', { bubbles: true });
      this.input.dispatchEvent(event);
    }

    removePill(className) {
      this.pills.delete(className);
      this.updateDisplay();
      this.updateOriginalInput();

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

    updateDisplay() {
      // Clear existing pills
      this.pillsContainer.innerHTML = '';

      // Add pills for each class
      this.pills.forEach(className => {
        const pill = this.createPill(className);
        this.pillsContainer.appendChild(pill);
      });

      // Update wrapper classes for styling
      this.wrapper.classList.toggle('has-pills', this.pills.size > 0);
    }

    updateOriginalInput() {
      const value = Array.from(this.pills).join(' ');
      this.input.value = value;
    }

    attachEvents() {
      // Handle input field keydown events
      this.inputField.addEventListener('keydown', (e) => {
        switch (e.key) {
          case 'Enter':
          case ' ':
            e.preventDefault();
            const text = this.inputField.value.trim();
            if (text) {
              this.parseAndAddPills(text);
              this.inputField.value = '';
            }
            break;
          case 'Backspace':
            if (this.inputField.value === '' && this.pills.size > 0) {
              e.preventDefault();
              // Remove the last pill
              const lastPill = Array.from(this.pills)[0];
              this.removePill(lastPill);
            }
            break;
        }
      });

      // Handle input field blur events - auto-add remaining text as pills
      this.inputField.addEventListener('blur', () => {
        const text = this.inputField.value.trim();
        if (text) {
          this.parseAndAddPills(text);
          this.inputField.value = '';
        }
      });

      // Handle paste events - split by spaces and add as pills
      this.inputField.addEventListener('paste', (e) => {
        setTimeout(() => {
          const text = this.inputField.value.trim();
          if (text) {
            this.parseAndAddPills(text);
            this.inputField.value = '';
          }
        }, 0);
      });

      // Handle clicks on the wrapper to focus input
      this.wrapper.addEventListener('click', (e) => {
        if (!e.target.classList.contains('bsfl-pillbox-pill-remove')) {
          this.inputField.focus();
        }
      });

    }

    setupMutationObserver() {
      // Watch for changes to the original input element
      if (typeof MutationObserver !== 'undefined') {
        this.mutationObserver = new MutationObserver((mutations) => {
          mutations.forEach(mutation => {
            if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
              // Sync with external changes to the original input
              const newValue = this.input.value.trim();
              const classes = newValue.split(/\s+/).filter(cls => cls.length > 0);
              this.pills.clear();
              classes.forEach(cls => {
                if (this.allowDuplicates || !this.pills.has(cls)) {
                  this.pills.add(cls);
                }
              });
              this.updateDisplay();
            }
          });
        });

        this.mutationObserver.observe(this.input, {
          attributes: true,
          attributeFilter: ['value']
        });
      }
    }

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

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

      // Destroy Sortable instance
      if (this.sortable) {
        this.sortable.destroy();
      }

      // 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() {
      PillBoxWidget.instances = PillBoxWidget.instances.filter(instance => {
        // Check if the wrapper still exists in the DOM
        return instance.wrapper && document.body.contains(instance.wrapper);
      });
    }
  }

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

      // Process all contexts, including AJAX-loaded content
      const inputs = context.querySelectorAll('input[data-bsfl-pillbox]');

      inputs.forEach(input => {
        // Only enhance if not already processed and not disabled
        if (!input.closest('.bsfl-pillbox-wrapper') && !input.disabled) {
          new PillBoxWidget(input);
        }
      });
    }
  };

  /**
   * Helper function to mark inputs for pillbox enhancement
   */
  Drupal.bsflPillBox = {
    enhance: function(selector) {
      const elements = document.querySelectorAll(selector || 'input[data-bsfl-pillbox]');
      elements.forEach(element => {
        if (!element.hasAttribute('data-bsfl-pillbox-processed')) {
          element.setAttribute('data-bsfl-pillbox', 'true');
          element.setAttribute('data-bsfl-pillbox-processed', 'true');
          Drupal.behaviors.bsflPillBox.attach(element);
        }
      });
    }
  };

})(Drupal, drupalSettings);

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

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