selectify-1.0.3/js/selectify-helper.js
js/selectify-helper.js
/**
* @file
* Contains utility functions for Selectify module multi-select.
*
* Filename: selectify-helper.js
* Website: https://www.flashwebcenter.com
* Developer: Alaa Haddad https://www.alaahaddad.com.
*/
((Drupal) => {
'use strict';
// Ensure Drupal.selectify namespace exists
Drupal.selectify = Drupal.selectify || {};
/**
* Handles selection limits for multi-select components.
* Shows a message with animation when the max selection limit is reached.
*
* @param {string} type - The type of selectify component (e.g., "checkbox", "dual", "dropdown").
* @param {HTMLElement} selectifySelect - The main Selectify wrapper element.
* @param {HTMLElement} nativeSelect - The corresponding hidden <select> element.
* @param {number|null} maxSelections - The maximum number of selections allowed.
* @param {string} itemSelector - The CSS selector for selectable options.
*/
Drupal.selectify.handleSelectionLimit = function(type, selectifySelect, nativeSelect, maxSelections, itemSelector) {
if (!Number.isInteger(maxSelections) || maxSelections <= 0) {
return;
}
if (!nativeSelect) {
return;
}
let selectedDisplay = type === 'dual' ?
selectifySelect.querySelector('.selectify-selected-display-dual') :
selectifySelect.querySelector('.selectify-selected-display');
const clearAllButton = selectifySelect.querySelector('.selectify-clear-all');
const maxSelectionMessage = selectifySelect.querySelector('.selectify-max-selection-message');
const items = selectifySelect.querySelectorAll(itemSelector);
let selectedCount = 0;
function showMaxSelectionMessage() {
if (maxSelectionMessage) {
maxSelectionMessage.classList.remove('hide');
maxSelectionMessage.classList.add('show');
setTimeout(() => {
maxSelectionMessage.classList.remove('show');
maxSelectionMessage.classList.add('hide');
}, 3000);
}
}
if (type === 'checkbox') {
const items = selectifySelect.querySelectorAll('.selectify-available-one-option input[type="checkbox"]');
once('selectifyCheckboxLimit', items).forEach((item) => {
item.addEventListener('change', function() {
// For maxSelections=1, uncheck all other checkboxes when checking a new one
if (maxSelections === 1 && this.checked) {
items.forEach(i => {
if (i !== this && i.checked) {
i.checked = false;
}
});
}
let selectedItems = [...items].filter(i => i.checked);
let selectedCount = selectedItems.length;
// Prevent selection if max is reached (except for maxSelections=1 where we replace)
if (this.checked && selectedCount > maxSelections && maxSelections !== 1) {
this.checked = false;
selectedCount--;
return;
}
const selectedValues = selectedItems.map(i => i.value);
// **Ensure unchecked checkboxes get disabled when max selection is reached**
let shouldShowMessage = false;
items.forEach(i => {
if (!i.checked) {
const wasEnabled = !i.disabled;
const isDisabled = selectedCount >= maxSelections;
i.disabled = isDisabled;
// Show message when first disabling (transition from enabled to disabled)
if (wasEnabled && isDisabled && maxSelections > 1) {
shouldShowMessage = true;
}
// Add aria-disabled and title for screen reader context
if (isDisabled) {
i.setAttribute('aria-disabled', 'true');
const parentOption = i.closest('.selectify-available-one-option');
if (parentOption) {
parentOption.setAttribute('aria-disabled', 'true');
parentOption.setAttribute('title', Drupal.t('Maximum selections reached'));
}
} else {
i.removeAttribute('aria-disabled');
const parentOption = i.closest('.selectify-available-one-option');
if (parentOption) {
parentOption.removeAttribute('aria-disabled');
parentOption.removeAttribute('title');
}
}
}
});
// Show message when reaching max limit (only for maxSelections > 1)
if (shouldShowMessage) {
showMaxSelectionMessage();
}
// Toggle "Clear All" button visibility
clearAllButton.classList.toggle('s-hidden', selectedValues.length === 0);
// Sync hidden select field & update display
Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
});
});
} else {
once('selectifyOptionLimit', items).forEach((item) => {
item.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
let selectedItems = [...items].filter(i => i.classList.contains('s-selected'));
selectedCount = selectedItems.length;
if (!item.classList.contains('s-selected') && selectedCount >= maxSelections && maxSelections !== 1) {
showMaxSelectionMessage();
return;
}
// Determine the value and toggle the hidden select's state directly.
const value = item.getAttribute('data-value') || item.textContent.trim();
const option = nativeSelect.querySelector(`option[value="${value}"]`);
if (option) {
// Check if field is single-value by looking at data-multiple attribute
const isMultiple = nativeSelect.getAttribute('data-multiple') === 'true';
// For single-value fields (maxSelections=1), deselect all other options first
if (maxSelections === 1 && !isMultiple) {
nativeSelect.querySelectorAll('option').forEach(opt => {
if (opt !== option) {
opt.selected = false;
}
});
option.selected = true;
} else {
// Normal toggle for multi-select
option.selected = !option.selected;
}
}
// After toggling, recalculate selected values (fresh from native select).
const selectedValues = Array.from(nativeSelect.options)
.filter(opt => opt.selected)
.map(opt => opt.value);
if (clearAllButton) {
clearAllButton.classList.toggle('s-hidden', selectedValues.length === 0);
}
Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
// Ensure the select field updates before dispatching the event.
setTimeout(() => {
nativeSelect.dispatchEvent(new Event('change', {
bubbles: true
}));
nativeSelect.dispatchEvent(new Event('input', {
bubbles: true
}));
}, 0);
});
});
}
};
/**
* Opens the dropdown while closing other open dropdowns.
*
* @param {HTMLElement} dropdown - The dropdown menu to open.
* @param {HTMLElement|null} triggerElement - The element that triggered the dropdown opening.
*/
Drupal.selectify.openDropdown = (dropdown, triggerElement = null) => {
if (!dropdown) return;
// Close all other open dropdowns except the one being opened.
document.querySelectorAll('.selectify-available-display.toggled').forEach(otherDropdown => {
if (otherDropdown !== dropdown) {
Drupal.selectify.closeDropdown(otherDropdown);
}
});
if (typeof Drupal.selectify.slideDown === 'function') {
Drupal.selectify.slideDown(dropdown);
}
const trigger = triggerElement || dropdown.previousElementSibling;
if (trigger) {
trigger.setAttribute('aria-expanded', 'true');
}
const parentSelect = triggerElement?.closest('.selectify-select');
if (parentSelect) {
parentSelect.classList.add('selectify-opened');
}
};
/**
* Closes the dropdown using the existing `slideUp` function.
*
* @param {HTMLElement} dropdown - The dropdown menu to close.
* @param {HTMLElement|null} triggerElement - The element that triggered the dropdown closing.
*/
Drupal.selectify.closeDropdown = (dropdown, triggerElement = null) => {
if (!dropdown) return;
if (typeof Drupal.selectify.slideUp === 'function') {
Drupal.selectify.slideUp(dropdown);
}
const trigger = triggerElement || dropdown.previousElementSibling;
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
if (typeof trigger.focus === 'function') {
trigger.focus();
}
}
const parentSelect = triggerElement?.closest('.selectify-select');
if (parentSelect) {
parentSelect.classList.remove('selectify-opened');
// Remove direction classes from parent only
parentSelect.classList.remove('opens-up', 'opens-down');
}
};
/**
* Synchronizes the hidden <select> field with the selected values.
*
* @param {HTMLElement} nativeSelect - The hidden select element.
* @param {string[]} selectedValues - The selected option values.
*/
Drupal.selectify.syncHiddenSelect = (nativeSelect, selectedValues) => {
if (!nativeSelect) return;
const noneOption = nativeSelect.querySelector('option[value="_none"]');
nativeSelect.querySelectorAll("option").forEach(option => {
if (option.value === '_none') {
// Only handle _none if it exists (don't invent it).
if (noneOption) {
option.selected = (selectedValues.length === 0);
option.toggleAttribute("selected", option.selected);
option.classList.toggle("s-selected", option.selected);
}
} else {
option.selected = selectedValues.includes(option.value);
option.toggleAttribute("selected", option.selected);
option.classList.toggle("s-selected", option.selected);
}
if (!option.classList.length) {
option.removeAttribute("class");
}
});
nativeSelect.dispatchEvent(new Event("change", {
bubbles: true
}));
nativeSelect.dispatchEvent(new Event("input", {
bubbles: true
}));
};
/**
* Adjusts dropdown height dynamically based on available space.
*
* @param {HTMLElement} selectifySelect - The selectify component.
* @param {HTMLElement} dropdownMenu - The dropdown menu to adjust.
*/
Drupal.selectify.adjustDropdownHeight = (selectifySelect, dropdownMenu) => {
const windowHeight = window.innerHeight;
const dropdownRect = selectifySelect.getBoundingClientRect();
const spaceBelow = windowHeight - dropdownRect.bottom;
const spaceAbove = dropdownRect.top;
let maxHeight = Math.min(spaceBelow - 20, 300);
if (maxHeight < 150 && spaceAbove > spaceBelow) {
// Open upward - not enough space below
maxHeight = Math.min(spaceAbove - 20, 300);
dropdownMenu.style.top = 'auto';
dropdownMenu.style.bottom = '100%';
// Add direction classes to parent only
selectifySelect.classList.add('opens-up');
selectifySelect.classList.remove('opens-down');
} else {
// Open downward - default behavior
dropdownMenu.style.top = '100%';
dropdownMenu.style.bottom = 'auto';
// Add direction classes to parent only
selectifySelect.classList.add('opens-down');
selectifySelect.classList.remove('opens-up');
}
dropdownMenu.style.maxHeight = `${maxHeight}px`;
dropdownMenu.style.overflowY = 'auto';
};
/**
* Updates the displayed selected options.
*/
/**
* Updates the displayed selected options.
*
* @param {string} type - The type of selectify component (e.g., "tags", "searchable", "dropdown", "checkbox", "dual").
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement} clearAllButton - The button to clear all selected options.
*/
Drupal.selectify.updateSelectedDisplay = (type, nativeSelect, selectedDisplay, clearAllButton) => {
const selectedValues = Drupal.selectify.getSelectedValues(nativeSelect);
if (!clearAllButton || !clearAllButton.closest('.selectify-select')) {
return;
}
// Announce widget is updating
const selectWidget = selectedDisplay.closest('.selectify-select');
if (selectWidget) {
selectWidget.setAttribute('aria-busy', 'true');
}
if (type !== 'dual') {
// Remove existing .selectify-selected-options while keeping the arrow.
const existingWrapper = selectedDisplay.querySelector('.selectify-selected-options');
if (existingWrapper) {
existingWrapper.remove();
}
}
// Handle tags & searchable types.
if (type === 'tags' || type === 'searchable') {
const placeholder = selectedDisplay.querySelector('.placeholder-text');
const arrowIcon = selectedDisplay.querySelector('.selectify-dorpdown-arrow');
Drupal.selectify.renderSelectifySearchTags(nativeSelect, selectedDisplay, placeholder, arrowIcon, selectedValues);
}
// Handle tags & searchable types.
if (type === 'dropdown') {
const placeholder = selectedDisplay.querySelector('.placeholder-text');
const arrowIcon = selectedDisplay.querySelector('.selectify-dorpdown-arrow');
Drupal.selectify.renderSelectifyDropdown(nativeSelect, selectedDisplay, placeholder, arrowIcon, selectedValues);
}
// Handle checkbox-based multi-select.
if (type === 'checkbox') {
const placeholder = selectedDisplay.querySelector('.placeholder-text');
const arrowIcon = selectedDisplay.querySelector('.selectify-dorpdown-arrow');
Drupal.selectify.renderSelectifyDropdownCheckbox(nativeSelect, selectedDisplay, arrowIcon, selectedValues);
}
// Handle dual-list multi-select.
if (type === 'dual') {
const dualSelectedArrow = selectedDisplay.querySelector('.dual-selected-arrow');
Drupal.selectify.renderSelectifyDual(nativeSelect, selectedDisplay, dualSelectedArrow, selectedValues);
}
const wrapper = selectedDisplay.closest('.selectify-select');
if (wrapper) {
const availableOptions = wrapper.querySelectorAll('.selectify-available-one-option');
availableOptions.forEach(option => {
const value = option.dataset.value;
const isSelected = selectedValues.includes(value);
Drupal.selectify.updateOptionState(option, isSelected, type);
});
}
// Ensure "Clear All" button visibility.
clearAllButton.classList.toggle('s-hidden', selectedValues.length === 0);
// Announce selection change to screen readers
if (selectWidget) {
Drupal.selectify.announceSelectionChange(selectWidget, selectedValues.length);
}
};
/**
* Renders the selected values inside the multi-select header (for tags & searchable).
*
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement|null} placeholder - The placeholder text element.
* @param {HTMLElement} arrowIcon - The dropdown arrow element.
* @param {string[]} selectedValues - The selected option values.
*/
Drupal.selectify.renderSelectifyDropdown = (nativeSelect, selectedDisplay, placeholder, arrowIcon, selectedValues) => {
if (selectedValues.length > 0) {
const wrapper = document.createElement('div');
wrapper.className = 'selectify-selected-options';
selectedValues.forEach(value => {
const option = nativeSelect.querySelector(`option[value="${value}"]`);
if (option) {
const itemDiv = document.createElement('div');
itemDiv.className = 'selectify-selected-one-option';
itemDiv.setAttribute('data-value', value);
const span = document.createElement('span');
span.className = 'option-label';
span.textContent = option.textContent;
itemDiv.appendChild(span);
wrapper.appendChild(itemDiv);
}
});
arrowIcon.parentNode.insertBefore(wrapper, arrowIcon);
}
};
/**
* Renders the selected values inside the multi-select header (for tags & searchable).
*
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement|null} placeholder - The placeholder text element.
* @param {HTMLElement} arrowIcon - The dropdown arrow element.
* @param {string[]} selectedValues - The selected option values.
*/
Drupal.selectify.renderSelectifySearchTags = (nativeSelect, selectedDisplay, placeholder, arrowIcon, selectedValues) => {
if (selectedValues.length > 0) {
const wrapper = document.createElement('div');
wrapper.className = 'selectify-selected-options';
selectedValues.forEach(value => {
const option = nativeSelect.querySelector(`option[value="${value}"]`);
if (option) {
const label = option.textContent.trim();
const itemDiv = document.createElement('div');
itemDiv.className = 'selectify-selected-one-option';
itemDiv.setAttribute('data-value', value);
const span = document.createElement('span');
span.className = 'option-label';
span.textContent = label;
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-tag';
removeBtn.type = 'button';
removeBtn.setAttribute('aria-label', Drupal.t('Remove @label', { '@label': label }));
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('fill', 'var(--selectify-select-arrow-color, var(--selectify-select-color))');
svg.setAttribute('class', 'icon-delete');
svg.setAttribute('height', '24px');
svg.setAttribute('viewBox', '0 -960 960 960');
svg.setAttribute('width', '24px');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z');
svg.appendChild(path);
removeBtn.appendChild(svg);
itemDiv.appendChild(span);
itemDiv.appendChild(removeBtn);
wrapper.appendChild(itemDiv);
}
});
arrowIcon.parentNode.insertBefore(wrapper, arrowIcon);
}
};
/**
* Renders the selected values inside the multi-select header (for checkbox-based selects).
*
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement} arrowIcon - The dropdown arrow element.
* @param {string[]} selectedValues - The selected option values.
*/
Drupal.selectify.renderSelectifyDropdownCheckbox = (nativeSelect, selectedDisplay, arrowIcon, selectedValues) => {
selectedDisplay.innerHTML = '';
if (selectedValues.length > 0) {
const wrapper = document.createElement('div');
wrapper.className = 'selectify-selected-options';
selectedValues.forEach(value => {
const option = nativeSelect.querySelector(`option[value="${value}"]`);
if (option) {
const span = document.createElement('span');
span.className = 'selectify-selected-one-option';
span.setAttribute('data-value', value);
span.textContent = option.textContent;
wrapper.appendChild(span);
}
});
selectedDisplay.appendChild(wrapper);
}
// Clone and append the dropdown arrow
const arrowClone = document.createElement('span');
arrowClone.className = 'selectify-dorpdown-arrow';
arrowClone.innerHTML = arrowIcon.innerHTML;
selectedDisplay.appendChild(arrowClone);
};
/**
* Renders the selected values inside the multi-select header (for dual-list selects).
*
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement|null} dualSelectedArrow - The arrow for dual-selected elements.
* @param {string[]} selectedValues - The selected option values.
*/
Drupal.selectify.renderSelectifyDual = (nativeSelect, selectedDisplay, dualSelectedArrow, selectedValues) => {
selectedDisplay.innerHTML = '';
if (selectedValues.length > 0) {
const wrapper = document.createElement('div');
wrapper.className = 'selectify-selected-options dual-inner';
selectedValues.forEach(value => {
const option = nativeSelect.querySelector(`option[value="${value}"]`);
if (option) {
const itemDiv = document.createElement('div');
itemDiv.className = 'selectify-selected-one-option dual-item';
itemDiv.setAttribute('data-value', value);
const label = document.createElement('label');
label.className = 'label-option';
label.textContent = option.textContent;
const arrowSpan = document.createElement('span');
arrowSpan.className = 'dual-selected-arrow';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('fill', 'var(--selectify-select-arrow-color, var(--selectify-select-color))');
svg.setAttribute('class', 'arrow-circle-left');
svg.setAttribute('height', '24px');
svg.setAttribute('viewBox', '0 -960 960 960');
svg.setAttribute('width', '24px');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'm480-320 56-56-64-64h168v-80H472l64-64-56-56-160 160 160 160Zm0 240q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z');
svg.appendChild(path);
arrowSpan.appendChild(svg);
itemDiv.appendChild(label);
itemDiv.appendChild(arrowSpan);
wrapper.appendChild(itemDiv);
}
});
selectedDisplay.appendChild(wrapper);
}
};
/**
* Toggles option selection.
*
* @param {string} type - The type of selectify component.
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} optionElement - The selected option element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement} clearAllButton - The button to clear all selected options.
*/
Drupal.selectify.toggleMultiSelectOption = function(type, nativeSelect, optionElement, selectedDisplay, clearAllButton) {
if (!type || !nativeSelect || !optionElement || !selectedDisplay) {
return;
}
const targetElement = optionElement.closest('.selectify-available-one-option');
if (!targetElement) return;
const value = targetElement.dataset.value;
const option = nativeSelect.querySelector(`option[value="${value}"]`);
const isExposedFilter = nativeSelect.closest('.views-exposed-form') !== null;
if (!option) {
return; // Safety check — invalid option should do nothing.
}
const noneOption = nativeSelect.querySelector('option[value="_none"]');
if (value === '_none' && noneOption) {
if (isExposedFilter) {
// Views filters should ignore _none entirely.
return;
}
// Regular fields — treat _none like "clear all."
nativeSelect.querySelectorAll('option').forEach(opt => opt.selected = (opt.value === '_none'));
Drupal.selectify.syncHiddenSelect(nativeSelect, ['_none']);
Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
clearAllButton.classList.add('s-hidden');
return;
}
// For single-value fields (maxSelections=1 or !multiple), deselect all other options first
const isMultiple = nativeSelect.hasAttribute('multiple');
if (!isMultiple) {
// Deselect all other options
nativeSelect.querySelectorAll('option').forEach(opt => {
if (opt !== option) {
opt.selected = false;
}
});
// Select this option
option.selected = true;
} else {
// Normal option toggle for multi-select
option.selected = !option.selected;
}
// If regular option is selected, unselect _none (if present).
if (option.selected && noneOption) {
noneOption.selected = false;
}
const selectedValues = Array.from(nativeSelect.options)
.filter(opt => opt.selected)
.map(opt => opt.value);
Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
};
/**
* Removes a selected tag.
*
* @param {string} type - The type of selectify component.
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @param {HTMLElement} tagElement - The tag element to remove.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
* @param {HTMLElement} clearAllButton - The button to clear all selected options.
*/
Drupal.selectify.removeTag = (type, nativeSelect, tagElement, selectedDisplay, clearAllButton) => {
if (!tagElement || !nativeSelect || !selectedDisplay) return;
const value = tagElement.dataset.value;
if (!value) return;
const option = nativeSelect.querySelector(`option[value="${value}"]`);
if (option) {
option.selected = false;
}
const dropdownMenu = selectedDisplay.closest('.selectify-select').querySelector('.selectify-available-display');
const optionElement = dropdownMenu.querySelector(`.selectify-available-one-option[data-value="${value}"]`);
if (optionElement) {
optionElement.classList.remove('s-selected', 's-hidden'); // Remove both classes correctly
}
// Sync the hidden select field.
const selectedValues = Array.from(nativeSelect.options)
.filter(opt => opt.selected)
.map(opt => opt.value);
// **Reinsert _none if no selections are left (only if field is not required)**
if (selectedValues.length === 0 && nativeSelect.hasAttribute("data-has-none")) {
const noneOption = nativeSelect.querySelector('option[value="_none"]');
if (noneOption) {
noneOption.selected = true;
}
}
Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues);
Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton);
tagElement.remove();
};
/**
* Retrieves selected values from a hidden `<select>`.
*
* @param {HTMLElement} nativeSelect - The hidden <select> element.
* @returns {string[]} - An array of selected option values.
*/
Drupal.selectify.getSelectedValues = (nativeSelect) => {
let selectedValues = Array.from(nativeSelect.options)
.filter(option => option.selected)
.map(option => option.value)
.filter(value => value !== '_none');
// **Ensure _none is reinserted when no other values are selected**
if (selectedValues.length === 0 && nativeSelect.dataset.hasNone === "true") {
const noneOption = nativeSelect.querySelector('option[value="_none"]');
if (noneOption) {
noneOption.selected = true;
selectedValues = ['_none'];
}
}
return selectedValues;
};
/**
* Handles keyboard navigation for multi-select components.
*
* @param {HTMLElement} selectifySelect - The main Selectify wrapper element.
* @param {HTMLElement} dropdownMenu - The dropdown menu element.
* @param {HTMLElement} selectedDisplay - The display area for selected options.
*/
Drupal.selectify.handleKeyboardNavigation = (selectifySelect, dropdownMenu, selectedDisplay, type) => {
// Ensure keyboard navigation event is only bound once per dropdown.
once('selectifyMultiSelectKeyboard', selectifySelect).forEach(() => {
selectifySelect.addEventListener('keydown', (event) => {
const key = event.key;
const isOpen = dropdownMenu.classList.contains('toggled');
const options = dropdownMenu.querySelectorAll('.selectify-available-one-option');
if ((key === 'Enter' || key === ' ') && !isOpen) {
event.preventDefault();
Drupal.selectify.openDropdown(dropdownMenu, selectedDisplay);
// Wait for dropdown animation to complete before focusing
setTimeout(() => {
const firstOption = options[0];
if (firstOption) {
// Set roving tabindex on first option
options.forEach(opt => opt.setAttribute('tabindex', '-1'));
firstOption.setAttribute('tabindex', '0');
firstOption.focus();
selectifySelect.setAttribute('aria-activedescendant', firstOption.id);
Drupal.selectify.updateOptionState(firstOption, true, type);
}
}, 50);
} else if (key === 'Escape' && isOpen) {
Drupal.selectify.closeDropdown(dropdownMenu, selectedDisplay);
} else if ((key === 'Enter' || key === ' ') && isOpen) {
event.preventDefault();
const currentFocused = document.activeElement.closest('.selectify-available-one-option');
if (currentFocused) {
currentFocused.click();
}
} else if (key === 'ArrowDown' || key === 'ArrowUp') {
event.preventDefault();
const currentFocused = document.activeElement.closest('.selectify-available-one-option');
let newIndex = -1;
if (!currentFocused) {
// Start at first/last option if nothing is currently focused.
newIndex = key === 'ArrowDown' ? 0 : options.length - 1;
} else {
const currentIndex = Array.from(options).indexOf(currentFocused);
newIndex = key === 'ArrowDown' ? currentIndex + 1 : currentIndex - 1;
}
if (newIndex >= 0 && newIndex < options.length) {
const newFocusedOption = options[newIndex];
// Implement roving tabindex pattern
options.forEach(opt => opt.setAttribute('tabindex', '-1'));
newFocusedOption.setAttribute('tabindex', '0');
newFocusedOption.focus();
// Update activedescendant and aria-selected
selectifySelect.setAttribute('aria-activedescendant', newFocusedOption.id);
options.forEach((option, index) => {
Drupal.selectify.updateOptionState(option, index === newIndex, type);
});
}
} else if (key === 'Tab' && isOpen) {
event.preventDefault();
const focusableElements = dropdownMenu.querySelectorAll(
'input:not([aria-hidden="true"]), button:not([aria-hidden="true"]), .selectify-available-one-option[tabindex="0"]'
);
if (focusableElements.length === 0) return;
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement);
let nextIndex;
if (event.shiftKey) {
nextIndex = currentIndex <= 0 ? focusableElements.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= focusableElements.length - 1 ? 0 : currentIndex + 1;
}
focusableElements[nextIndex].focus();
// Update aria-activedescendant if focusing an option
if (focusableElements[nextIndex].classList.contains('selectify-available-one-option')) {
selectifySelect.setAttribute('aria-activedescendant', focusableElements[nextIndex].id);
}
}
});
});
};
/**
* Updates selected state and applies "hidden" (for types like tags that remove selected items).
*/
Drupal.selectify.updateOptionState = (optionElement, isSelected, type) => {
if (!optionElement || !type) {
return;
}
const value = optionElement.dataset.value;
if (!value) return;
const isNoneOrAll = (value === '_none' || value === 'All');
const isExposedFilter = optionElement.closest('.views-exposed-form') !== null;
// Update aria-activedescendant on parent widget when option is focused
if (document.activeElement === optionElement || optionElement.contains(document.activeElement)) {
const selectWidget = optionElement.closest('.selectify-select');
if (selectWidget && optionElement.id) {
selectWidget.setAttribute('aria-activedescendant', optionElement.id);
}
}
if (type === 'checkbox') {
const checkbox = optionElement.querySelector('input[type="checkbox"]');
if (checkbox) {
// For views exposed form, trust the actual checkbox state.
if (isExposedFilter) {
isSelected = checkbox.checked;
}
checkbox.setAttribute('aria-checked', isSelected ? 'true' : 'false');
checkbox.checked = isSelected;
}
optionElement.classList.toggle('s-selected', isSelected);
} else {
if (!(isExposedFilter && isNoneOrAll)) {
if (isSelected) {
optionElement.setAttribute('aria-selected', 'true');
} else {
optionElement.removeAttribute('aria-selected');
}
optionElement.classList.toggle('s-selected', isSelected);
}
if (type === 'searchable' || type === 'tags' || type === 'dual') {
optionElement.classList.toggle('s-hidden', isSelected);
}
}
};
/**
* Ensures aria-labelledby is added when a <label> exists before .wrapper-selectify.
*/
Drupal.selectify.injectAriaLabelledBy = function () {
document.querySelectorAll('.selectify-select').forEach(widget => {
const wrapper = widget.closest('.wrapper-selectify');
if (!wrapper) return;
const label = wrapper.previousElementSibling;
if (!label || label.tagName.toLowerCase() !== 'label') return;
let labelId = label.getAttribute('id');
if (!labelId) {
const forAttr = label.getAttribute('for');
if (forAttr) {
labelId = `${forAttr}-label`;
} else {
labelId = 'selectify-label-' + Math.random().toString(36).substring(2, 10);
}
label.setAttribute('id', labelId);
}
if (!widget.hasAttribute('aria-labelledby')) {
widget.setAttribute('aria-labelledby', labelId);
}
});
};
/**
* Announces selection changes to screen readers.
* Debounced to prevent announcement spam during rapid selections.
*
* @param {HTMLElement} selectWidget - The main Selectify widget element.
* @param {number} count - Number of selected items.
*/
Drupal.selectify.announceSelectionChange = (function() {
let announcementTimer = null;
return function(selectWidget, count) {
const liveRegion = selectWidget.querySelector('[role="status"][aria-live="polite"]');
if (!liveRegion) return;
// Clear previous timer to debounce
if (announcementTimer) {
clearTimeout(announcementTimer);
}
// Wait 500ms after last change before announcing
announcementTimer = setTimeout(() => {
liveRegion.textContent = Drupal.t('@count selected', {'@count': count});
}, 500);
};
})();
/**
* Ensures dropdown close event is only bound once.
*/
once('selectifyMultiSelectCloseDropdown', document).forEach(() => {
document.addEventListener('click', (event) => {
if (!event.target.closest('.selectify-available-display') && !event.target.closest('.selectify-selected-display')) {
document.querySelectorAll('.selectify-available-display.toggled').forEach(dropdown => {
Drupal.selectify.closeDropdown(dropdown);
});
}
});
});
})(Drupal);
