1/** 2 * @file 3 * Contains utility functions for Selectify module multi-select. 4 * 5 * Filename: selectify-helper.js 6 * Website: https://www.flashwebcenter.com 7 * Developer: Alaa Haddad https://www.alaahaddad.com. 8 */ 9((Drupal, drupalSettings) => { 10 'use strict'; 11 // Ensure Drupal.selectify namespace exists 12 Drupal.selectify = Drupal.selectify || {}; 13 14 /** 15 * Debug logging helper (only logs if debug mode is enabled). 16 * 17 * @param {string} message - The message to log. 18 * @param {...*} args - Additional arguments to log. 19 */ 20 const debugLog = (message, ...args) => { 21 if (drupalSettings?.selectify?.debug) { 22 console.warn('[Selectify]', message, ...args); 23 } 24 }; 25 /** 26 * Reports init failure (only when debug is on). Use in widget bootstrap. 27 * 28 * @param {string} widgetName - Widget name for the log (e.g. "Dropdown", "Dual"). 29 * @param {string} message - Failure message. 30 */ 31 Drupal.selectify.reportInitFailure = (widgetName, message) => { 32 debugLog(`${widgetName}: ${message}`); 33 }; 34 /** 35 * Shared widget bootstrap: resolve native select, run init, selection limit, dropdown id/aria. 36 * Call from each behavior after once(selector, context). Keeps dual and dropdown widgets 37 * consistent; dual passes dropdownPanelSelector '.dual-available', others '.selectify-available-display'. 38 * 39 * @param {HTMLElement} selectifySelect - The widget wrapper (already matched by once). 40 * @param {string} widgetName - Name for debug messages (e.g. "Dropdown", "Dual", "Tags"). 41 * @param {string} type - Widget type: 'dropdown'|'tags'|'searchable'|'checkbox'|'dual'. 42 * @param {Object} options - Bootstrap options. 43 * @param {function(HTMLElement, HTMLSelectElement)} options.initFn - (selectifySelect, nativeSelect) runs widget-specific init. 44 * @param {string} [options.dropdownPanelSelector] - Selector for the dropdown/available panel to set id and aria-controls (e.g. '.selectify-available-display' or '.dual-available'). 45 * @param {string} [options.itemSelector='.selectify-available-one-option'] - Selector for selectable options (handleSelectionLimit). 46 */ 47 Drupal.selectify.bootstrapWidget = (selectifySelect, widgetName, type, options) => { 48 const targetId = selectifySelect.getAttribute('data-target-id'); 49 if (!targetId) { 50 Drupal.selectify.reportInitFailure(widgetName, 'missing data-target-id attribute'); 51 return; 52 } 53 const nativeSelect = document.getElementById(targetId); 54 if (!nativeSelect || nativeSelect.tagName !== 'SELECT') { 55 Drupal.selectify.reportInitFailure(widgetName, `native select element not found: ${targetId}`); 56 return; 57 } 58 if (options.initFn) { 59 options.initFn(selectifySelect, nativeSelect); 60 } 61 let maxSelections = selectifySelect.getAttribute('data-max-selections'); 62 maxSelections = maxSelections === 'null' ? null : parseInt(maxSelections, 10); 63 const itemSelector = options.itemSelector || '.selectify-available-one-option'; 64 Drupal.selectify.handleSelectionLimit(type, selectifySelect, nativeSelect, maxSelections, itemSelector); 65 if (options.dropdownPanelSelector) { 66 const panel = selectifySelect.querySelector(options.dropdownPanelSelector); 67 if (panel) { 68 panel.setAttribute('id', `${targetId}-dropdown`); 69 selectifySelect.setAttribute('aria-controls', `${targetId}-dropdown`); 70 } 71 } 72 Drupal.selectify.injectAriaLabelledBy(); 73 }; 74 /** 75 * Shared "Clear All" handler: clear to "All" or deselect all, update UI, dispatch events, focus. 76 * Dual and dropdown widgets pass their own onDeselectAll and focusTarget so behavior stays correct. 77 * 78 * @param {string} type - Widget type: 'dropdown'|'tags'|'searchable'|'checkbox'|'dual'. 79 * @param {HTMLElement} selectifySelect - The widget wrapper. 80 * @param {HTMLSelectElement} nativeSelect - The hidden <select>. 81 * @param {HTMLElement} selectedDisplay - The display area for selected options. 82 * @param {HTMLElement|null} clearAllButton - The Clear button (for updateSelectedDisplay). 83 * @param {HTMLElement|null} dropdownMenu - The available-options panel (for onDeselectAll). 84 * @param {Object} options - Clear options. 85 * @param {function(HTMLElement, HTMLElement, HTMLSelectElement)} [options.onDeselectAll] - (dropdownMenu, selectedDisplay, nativeSelect) widget-specific DOM reset when deselecting all. 86 * @param {function(HTMLElement): (HTMLElement|null)} [options.focusTarget] - (selectifySelect) return element to focus after clear (e.g. selected display or dual available list). 87 */ 88 Drupal.selectify.executeClear = (type, selectifySelect, nativeSelect, selectedDisplay, clearAllButton, dropdownMenu, options) => { 89 const targetValues = Drupal.selectify.getClearTargetValues(nativeSelect, selectifySelect); 90 if (targetValues.length > 0) { 91 nativeSelect.querySelectorAll('option').forEach((opt) => { 92 opt.selected = (opt.value === 'All'); 93 }); 94 Drupal.selectify.syncHiddenSelect(nativeSelect, targetValues); 95 } else { 96 nativeSelect.querySelectorAll('option').forEach((opt) => { 97 opt.selected = false;
98 }); 99 if (options.onDeselectAll && dropdownMenu) { 100 options.onDeselectAll(dropdownMenu, selectedDisplay, nativeSelect); 101 } 102 Drupal.selectify.syncHiddenSelect(nativeSelect, []); 103 } 104 Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton); 105 setTimeout(() => { 106 nativeSelect.dispatchEvent(new Event('change', { bubbles: true })); 107 nativeSelect.dispatchEvent(new Event('input', { bubbles: true })); 108 }, 0); 109 const constants = Drupal.selectify?.constants || {}; 110 const focusDelay = constants.FOCUS_DELAY || 100; 111 if (options.focusTarget) { 112 const el = options.focusTarget(selectifySelect); 113 if (el && typeof el.focus === 'function') { 114 setTimeout(() => el.focus(), focusDelay); 115 } 116 } 117 }; 118 /** 119 * Handles selection limits for multi-select components. 120 * Shows a message with animation when the max selection limit is reached. 121 * 122 * @param {string} type - The type of selectify component (e.g., "checkbox", "dual", "dropdown"). 123 * @param {HTMLElement} selectifySelect - The main Selectify wrapper element. 124 * @param {HTMLElement} nativeSelect - The corresponding hidden <select> element. 125 * @param {number|null} maxSelections - The maximum number of selections allowed. 126 * @param {string} itemSelector - The CSS selector for selectable options. 127 */ 128 Drupal.selectify.handleSelectionLimit = function(type, selectifySelect, nativeSelect, maxSelections, itemSelector) { 129 if (!Number.isInteger(maxSelections) || maxSelections <= 0) { 130 return; 131 } 132 if (!nativeSelect) { 133 return; 134 } 135 let selectedDisplay = type === 'dual' ? 136 selectifySelect.querySelector('.selectify-selected-display-dual') : 137 selectifySelect.querySelector('.selectify-selected-display'); 138 const clearAllButton = selectifySelect.querySelector('.selectify-clear-all'); 139 const maxSelectionMessage = selectifySelect.querySelector('.selectify-max-selection-message'); 140 const items = selectifySelect.querySelectorAll(itemSelector); 141 let selectedCount = 0; 142 143 function showMaxSelectionMessage() { 144 if (maxSelectionMessage) { 145 const constants = Drupal.selectify?.constants || {}; 146 const dismissDelay = constants.MESSAGE_AUTO_DISMISS || 3000; 147 maxSelectionMessage.classList.remove('hide'); 148 maxSelectionMessage.classList.add('show'); 149 setTimeout(() => { 150 maxSelectionMessage.classList.remove('show'); 151 maxSelectionMessage.classList.add('hide'); 152 }, dismissDelay); 153 } 154 } 155 if (type === 'checkbox') { 156 const items = selectifySelect.querySelectorAll('.selectify-available-one-option input[type="checkbox"]'); 157 once('selectifyCheckboxLimit', items).forEach((item) => { 158 item.addEventListener('change', function() { 159 // For maxSelections=1, uncheck all other checkboxes when checking a new one (click-to-switch, like other widgets) 160 if (maxSelections === 1 && this.checked) { 161 items.forEach(i => { 162 if (i !== this && i.checked) { 163 i.checked = false; 164 const parent = i.closest('.selectify-available-one-option'); 165 if (parent) { 166 parent.classList.remove('s-selected'); 167 } 168 } 169 }); 170 } 171 172 let selectedItems = [...items].filter(i => i.checked); 173 let selectedCount = selectedItems.length; 174 // Prevent selection if max is reached (except for maxSelections=1 where we replace) 175 if (this.checked && selectedCount > maxSelections && maxSelections !== 1) { 176 this.checked = false; 177 selectedCount--; 178 return; 179 } 180 const selectedValues = selectedItems.map(i => i.value); 181 // **Disable unchecked checkboxes only when max selection is reached (and multi-select). For single (maxSelections=1), never disable so user can click another to switch.** 182 let shouldShowMessage = false; 183 items.forEach(i => { 184 if (!i.checked) { 185 const wasEnabled = !i.disabled; 186 const isDisabled = maxSelections > 1 && selectedCount >= maxSelections; 187 i.disabled = isDisabled; 188 189 // Show message when first disabling (transition from enabled to disabled) 190 if (wasEnabled && isDisabled && maxSelections > 1) { 191 shouldShowMessage = true; 192 } 193 194 // Add aria-disabled and title for screen reader context 195 if (isDisabled) { 196 i.setAttribute('aria-disabled', 'true'); 197 const parentOption = i.closest('.selectify-available-one-option'); 198 if (parentOption) { 199 parentOption.setAttribute('aria-disabled', 'true'); 200 parentOption.setAttribute('title', Drupal.t('Maximum selections reached')); 201 } 202 } else { 203 i.removeAttribute('aria-disabled'); 204 const parentOption = i.closest('.selectify-available-one-option'); 205 if (parentOption) { 206 parentOption.removeAttribute('aria-disabled'); 207 parentOption.removeAttribute('title'); 208 } 209 } 210 } 211 }); 212 213 // Show message when reaching max limit (only for maxSelections > 1) 214 if (shouldShowMessage) { 215 showMaxSelectionMessage(); 216 } 217 // Sync hidden select field & update display 218 Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues); 219 Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton); 220 }); 221 }); 222 } else {
223 once('selectifyOptionLimit', items).forEach((item) => { 224 item.addEventListener('click', function(event) { 225 event.preventDefault(); 226 event.stopPropagation(); 227 let selectedItems = [...items].filter(i => i.classList.contains('s-selected')); 228 selectedCount = selectedItems.length; 229 if (!item.classList.contains('s-selected') && selectedCount >= maxSelections && maxSelections !== 1) { 230 showMaxSelectionMessage(); 231 return; 232 } 233 // Determine the value and toggle the hidden select's state directly. 234 const value = item.getAttribute('data-value') || item.textContent.trim(); 235 const option = nativeSelect.querySelector(`option[value="${value}"]`); 236 if (option) { 237 // Check if field is single-value by looking at data-multiple attribute 238 const isMultiple = nativeSelect.getAttribute('data-multiple') === 'true'; 239 240 // For single-value fields (maxSelections=1), deselect all other options first 241 if (maxSelections === 1 && !isMultiple) { 242 nativeSelect.querySelectorAll('option').forEach(opt => { 243 if (opt !== option) { 244 opt.selected = false; 245 } 246 }); 247 option.selected = true; 248 } else { 249 // Normal toggle for multi-select 250 option.selected = !option.selected; 251 } 252 } 253 // After toggling, recalculate selected values (fresh from native select). 254 const selectedValues = Array.from(nativeSelect.options) 255 .filter(opt => opt.selected) 256 .map(opt => opt.value); 257 Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues); 258 Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton); 259 // Ensure the select field updates before dispatching the event. 260 setTimeout(() => { 261 nativeSelect.dispatchEvent(new Event('change', { 262 bubbles: true 263 })); 264 nativeSelect.dispatchEvent(new Event('input', { 265 bubbles: true 266 })); 267 }, 0); 268 }); 269 }); 270 } 271 }; 272 /** 273 * Opens the dropdown while closing other open dropdowns. 274 * 275 * @param {HTMLElement} dropdown - The dropdown menu to open. 276 * @param {HTMLElement|null} triggerElement - The element that triggered the dropdown opening. 277 */ 278 Drupal.selectify.openDropdown = (dropdown, triggerElement = null) => { 279 if (!dropdown) return; 280 281 // Close all other open dropdowns except the one being opened. 282 document.querySelectorAll('.selectify-available-display.toggled').forEach(otherDropdown => { 283 if (otherDropdown !== dropdown) { 284 Drupal.selectify.closeDropdown(otherDropdown); 285 } 286 }); 287 288 if (typeof Drupal.selectify.slideDown === 'function') { 289 Drupal.selectify.slideDown(dropdown); 290 } 291 292 const combobox = dropdown.closest('[role="combobox"]'); 293 if (combobox) { 294 combobox.setAttribute('aria-expanded', 'true'); 295 } 296 297 const parentSelect = triggerElement?.closest('.selectify-select'); 298 if (parentSelect) { 299 parentSelect.classList.add('selectify-opened'); 300 } 301 }; 302 /** 303 * Closes the dropdown using the existing `slideUp` function. 304 * 305 * @param {HTMLElement} dropdown - The dropdown menu to close. 306 * @param {HTMLElement|null} triggerElement - The element that triggered the dropdown closing. 307 */ 308 Drupal.selectify.closeDropdown = (dropdown, triggerElement = null) => { 309 if (!dropdown) return; 310 311 if (typeof Drupal.selectify.slideUp === 'function') { 312 Drupal.selectify.slideUp(dropdown); 313 } 314 315 const combobox = dropdown.closest('[role="combobox"]'); 316 if (combobox) { 317 combobox.setAttribute('aria-expanded', 'false'); 318 } 319 const trigger = triggerElement || dropdown.previousElementSibling; 320 if (trigger && typeof trigger.focus === 'function') { 321 trigger.focus(); 322 } 323 324 const parentSelect = dropdown.closest('.selectify-select'); 325 if (parentSelect) { 326 parentSelect.classList.remove('selectify-opened'); 327 // Remove direction classes from parent only 328 parentSelect.classList.remove('opens-up', 'opens-down'); 329 } 330 }; 331 /** 332 * Synchronizes the hidden <select> field with the selected values. 333 * 334 * @param {HTMLElement} nativeSelect - The hidden select element. 335 * @param {string[]} selectedValues - The selected option values. 336 */ 337 Drupal.selectify.syncHiddenSelect = (nativeSelect, selectedValues) => { 338 if (!nativeSelect) return; 339 const noneOption = nativeSelect.querySelector('option[value="_none"]'); 340 nativeSelect.querySelectorAll("option").forEach(option => { 341 if (option.value === '_none') { 342 // Only handle _none if it exists (don't invent it). 343 if (noneOption) { 344 option.selected = (selectedValues.length === 0); 345 option.toggleAttribute("selected", option.selected); 346 option.classList.toggle("s-selected", option.selected); 347 } 348 } else { 349 option.selected = selectedValues.includes(option.value); 350 option.toggleAttribute("selected", option.selected); 351 option.classList.toggle("s-selected", option.selected); 352 } 353 if (!option.classList.length) { 354 option.removeAttribute("class"); 355 } 356 }); 357 nativeSelect.dispatchEvent(new Event("change", { 358 bubbles: true 359 })); 360 nativeSelect.dispatchEvent(new Event("input", { 361 bubbles: true 362 })); 363 }; 364 /** 365 * Adjusts dropdown height dynamically based on available space. 366 * 367 * @param {HTMLElement} selectifySelect - The selectify component. 368 * @param {HTMLElement} dropdownMenu - The dropdown menu to adjust. 369 */ 370 Drupal.selectify.adjustDropdownHeight = (selectifySelect, dropdownMenu) => { 371 const windowHeight = window.innerHeight; 372 const dropdownRect = selectifySelect.getBoundingClientRect(); 373 const spaceBelow = windowHeight - dropdownRect.bottom; 374 const spaceAbove = dropdownRect.top; 375 let maxHeight = Math.min(spaceBelow - 20, 300); 376 if (maxHeight < 150 && spaceAbove > spaceBelow) { 377 // Open upward - not enough space below 378 maxHeight = Math.min(spaceAbove - 20, 300); 379 dropdownMenu.style.top = 'auto'; 380 dropdownMenu.style.bottom = '100%'; 381 // Add direction classes to parent only 382 selectifySelect.classList.add('opens-up'); 383 selectifySelect.classList.remove('opens-down'); 384 } else { 385 // Open downward - default behavior 386 dropdownMenu.style.top = '100%'; 387 dropdownMenu.style.bottom = 'auto'; 388 // Add direction classes to parent only 389 selectifySelect.classList.add('opens-down'); 390 selectifySelect.classList.remove('opens-up'); 391 } 392 dropdownMenu.style.maxHeight = `${maxHeight}px`; 393 dropdownMenu.style.overflowY = 'auto'; 394 }; 395 /** 396 * Toggles option selection. 397 * 398 * @param {string} type - The type of selectify component. 399 * @param {HTMLElement} nativeSelect - The hidden <select> element. 400 * @param {HTMLElement} optionElement - The selected option element. 401 * @param {HTMLElement} selectedDisplay - The display area for selected options. 402 * @param {HTMLElement} clearAllButton - The button to clear all selected options. 403 */ 404Drupal.selectify.toggleMultiSelectOption = function(type, nativeSelect, optionElement, selectedDisplay, clearAllButton) { 405 if (!type || !nativeSelect || !optionElement || !selectedDisplay) { 406 return; 407 } 408 const targetElement = optionElement.closest('.selectify-available-one-option'); 409 if (!targetElement) return; 410 const value = targetElement.dataset.value; 411 const option = nativeSelect.querySelector(`option[value="${value}"]`); 412 const isExposedFilter = nativeSelect.closest('.views-exposed-form') !== null; 413 if (!option) { 414 return;
414 // Safety check â invalid option should do nothing. 415 } 416 const noneOption = nativeSelect.querySelector('option[value="_none"]'); 417 if (value === '_none' && noneOption) { 418 if (isExposedFilter) { 419 // Views filters should ignore _none entirely. 420 return; 421 } 422 // Regular fields â treat _none like "clear all." 423 nativeSelect.querySelectorAll('option').forEach(opt => opt.selected = (opt.value === '_none')); 424 Drupal.selectify.syncHiddenSelect(nativeSelect, ['_none']); 425 Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton); 426 return; 427 } 428 // For single-value fields (maxSelections=1 or !multiple), deselect all other options first 429 const isMultiple = nativeSelect.hasAttribute('multiple'); 430 if (!isMultiple) { 431 // Deselect all other options 432 nativeSelect.querySelectorAll('option').forEach(opt => { 433 if (opt !== option) { 434 opt.selected = false; 435 } 436 }); 437 // Select this option 438 option.selected = true; 439 } else { 440 // Normal option toggle for multi-select 441 option.selected = !option.selected; 442 } 443 // If regular option is selected, unselect _none (if present). 444 if (option.selected && noneOption) { 445 noneOption.selected = false; 446 } 447 const selectedValues = Array.from(nativeSelect.options) 448 .filter(opt => opt.selected) 449 .map(opt => opt.value); 450 Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues); 451 Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton); 452 }; 453 /** 454 * Removes a selected tag. 455 * 456 * @param {string} type - The type of selectify component. 457 * @param {HTMLElement} nativeSelect - The hidden <select> element. 458 * @param {HTMLElement} tagElement - The tag element to remove. 459 * @param {HTMLElement} selectedDisplay - The display area for selected options. 460 * @param {HTMLElement} clearAllButton - The button to clear all selected options. 461 */ 462 Drupal.selectify.removeTag = (type, nativeSelect, tagElement, selectedDisplay, clearAllButton) => { 463 if (!tagElement || !nativeSelect || !selectedDisplay) return; 464 465 const value = tagElement.dataset.value; 466 if (!value) return; 467 const option = nativeSelect.querySelector(`option[value="${value}"]`); 468 if (option) { 469 option.selected = false; 470 } 471 const wrapper = selectedDisplay.closest('.selectify-select'); 472 const dropdownMenu = wrapper ? wrapper.querySelector('.selectify-available-display') : null; 473 if (!dropdownMenu) return; 474 const optionElement = dropdownMenu.querySelector(`.selectify-available-one-option[data-value="${value}"]`); 475 if (optionElement) { 476 optionElement.classList.remove('s-selected', 's-hidden'); // Remove both classes correctly 477 } 478 // Sync the hidden select field. 479 const selectedValues = Array.from(nativeSelect.options) 480 .filter(opt => opt.selected) 481 .map(opt => opt.value); 482 // **Reinsert _none if no selections are left (only if field is not required)** 483 if (selectedValues.length === 0 && nativeSelect.hasAttribute("data-has-none")) { 484 const noneOption = nativeSelect.querySelector('option[value="_none"]'); 485 if (noneOption) { 486 noneOption.selected = true; 487 } 488 } 489 Drupal.selectify.syncHiddenSelect(nativeSelect, selectedValues); 490 Drupal.selectify.updateSelectedDisplay(type, nativeSelect, selectedDisplay, clearAllButton); 491 tagElement.remove(); 492 }; 493 /** 494 * Retrieves selected values from a hidden `<select>`. 495 * 496 * @param {HTMLElement} nativeSelect - The hidden <select> element. 497 * @returns {string[]} - An array of selected option values. 498 */ 499 Drupal.selectify.getSelectedValues = (nativeSelect) => { 500 let selectedValues = Array.from(nativeSelect.options) 501 .filter(option => option.selected) 502 .map(option => option.value) 503 .filter(value => value !== '_none'); 504 // **Ensure _none is reinserted when no other values are selected** 505 if (selectedValues.length === 0 && nativeSelect.dataset.hasNone === "true") { 506 const noneOption = nativeSelect.querySelector('option[value="_none"]'); 507 if (noneOption) { 508 noneOption.selected = true; 509 selectedValues = ['_none']; 510 } 511 } 512 return selectedValues; 513 }; 514 /** 515 * For "Clear All": single-select with an "All" option should reset to "All", not empty. 516 * 517 * @param {HTMLSelectElement} nativeSelect - The hidden <select> element. 518 * @param {HTMLElement} selectWidget - The Selectify widget wrapper (for data-multiple). 519 * @returns {string[]} [] to deselect all, or ['All'] to select the "All" option. 520 */ 521 Drupal.selectify.getClearTargetValues = (nativeSelect, selectWidget) => { 522 const isMultiple = selectWidget && selectWidget.getAttribute('data-multiple') === 'true'; 523 if (isMultiple) { 524 return []; 525 } 526 const hasAll = Array.from(nativeSelect.options).some(opt => opt.value === 'All'); 527 return hasAll ? ['All'] : []; 528 }; 529 /** 530 * Close any open dropdown when clicking outside. 531 * Bound at module level (IIFE), so it only runs once per page load. 532 */ 533 document.addEventListener('click', (event) => { 534 if (!event.target.closest('.selectify-available-display') && !event.target.closest('.selectify-selected-display')) { 535 document.querySelectorAll('.selectify-available-display.toggled').forEach(dropdown => { 536 Drupal.selectify.closeDropdown(dropdown); 537 }); 538 } 539 }); 540})(Drupal, drupalSettings);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.