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);
