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