1/** 2 * @file 3 * Attaches behaviors for the Chosen module. 4 */ 5(function(Drupal, drupalSettings, once) { 6 'use strict'; 7 8 // Update Chosen elements when state has changed. 9 document.addEventListener('state:disabled', function(e) { 10 if (e.target && e.target.matches('select')) { 11 const event = new Event('chosen:updated', { bubbles: true, cancelable: true }); 12 e.target.dispatchEvent(event); 13 } 14 }); 15 16 Drupal.behaviors.chosen = { 17 18 settings: { 19 20 /** 21 * Completely ignores elements that match one of these selectors. 22 * 23 * Disabled on: 24 * - Field UI 25 * - WYSIWYG elements 26 * - Tabledrag weights 27 * - Elements that have opted-out of Chosen 28 * - Elements already processed by Chosen. 29 * 30 * @type {string} 31 */ 32 ignoreSelector: '#field-ui-field-storage-add-form select, #entity-form-display-edit-form select, #entity-view-display-edit-form select, .wysiwyg, .draggable select[name$="[weight]"], .draggable select[name$="[position]"], .locale-translate-filter-form select, .chosen-disable, .chosen-processed', 33 34 /** 35 * Explicit "opt-in" selector. 36 * 37 * @type {string} 38 */ 39 optedInSelector: 'select.chosen-enable', 40 41 /** 42 * The default selector, overridden by drupalSettings. 43 * 44 * @type {string} 45 */ 46 selector: 'select:visible', 47 48 /** 49 * Minimum options for single and multiple selects. 50 * These may be defined in drupalSettings.chosen. 51 */ 52 minimum_single: 0, 53 minimum_multiple: 0, 54 55 /** 56 * Other default options for Chosen. 57 */ 58 options: {}, 59 minimum_width: 0, 60 use_relative_width: false, 61 }, 62 63 /** 64 * Drupal attach behavior. 65 */ 66 attach: function(context, settings) { 67 this.settings = this.getSettings(settings); 68 const elements = this.getElements(context); 69 // Use Drupal's once function to process elements only once 70 once('chosen', elements).forEach(function(element) { 71 // If inside a Drupal dialog, resize the dialog on open & close event. 72 if (element.closest('#drupal-modal')) { 73 element.addEventListener('chosen:showing_dropdown', (e) => { 74 const nextElement = element.nextElementSibling; 75 if (nextElement) { 76 const chosenDrop = nextElement.querySelector('.chosen-drop'); 77 if (chosenDrop) { 78 chosenDrop.style.position = 'static'; 79 const event = new Event('dialogContentResize', { bubbles: true, cancelable: true }); 80 element.dispatchEvent(event); 81 } 82 } 83 }); 84 element.addEventListener('chosen:hiding_dropdown', (e) => { 85 const nextElement = element.nextElementSibling; 86 if (nextElement) { 87 const chosenDrop = nextElement.querySelector('.chosen-drop'); 88 if (chosenDrop) { 89 chosenDrop.style.position = ''; 90 const event = new Event('dialogContentResize', { bubbles: true, cancelable: true }); 91 element.dispatchEvent(event); 92 } 93 } 94 }); 95 } 96 this.createChosen(element); 97 }.bind(this)); 98 }, 99 100 /** 101 * Creates a Chosen instance for a specific element. 102 * 103 * @param {HTMLElement} element 104 * The element. 105 */ 106 createChosen: function(element) { 107 const options = this.getElementOptions(element); 108 element.chosen(options); 109 this.observeDisabledState(element); 110 111 if (options.add_helper_buttons || element.hasAttribute('chosen_add_helper_buttons')) { 112 if (element.hasAttribute('multiple')) { 113 const allButton = document.createElement('button'); 114 allButton.type = 'button'; 115 allButton.className = 'button chosen-helper-btn'; 116 allButton.textContent = 'All'; 117 allButton.addEventListener('click', function() { 118 Array.from(element.options).forEach(function(option) { 119 option.selected = true; 120 }); 121 const event = new Event('chosen:updated', { bubbles: true, cancelable: true }); 122 element.dispatchEvent(event); 123 }); 124 125 const noneButton = document.createElement('button'); 126 noneButton.type = 'button'; 127 noneButton.className = 'button chosen-helper-btn'; 128 noneButton.textContent = 'None'; 129 noneButton.addEventListener('click', function() {
130 Array.from(element.options).forEach(function(option) { 131 option.selected = false; 132 }); 133 const event = new Event('chosen:updated', { bubbles: true, cancelable: true }); 134 element.dispatchEvent(event); 135 }); 136 137 element.parentNode.appendChild(allButton); 138 element.parentNode.appendChild(noneButton); 139 } 140 } 141 }, 142 143 /** 144 * Observes disabled attribute changes on a Chosen-managed select. 145 * 146 * @param {HTMLElement} element 147 * The select element. 148 */ 149 observeDisabledState: function(element) { 150 if (element.__chosenDisabledObserver) { 151 return; 152 } 153 154 const observer = new MutationObserver(function(mutations) { 155 mutations.forEach(function(mutation) { 156 if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') { 157 const event = new Event('chosen:updated', { bubbles: true, cancelable: true }); 158 element.dispatchEvent(event); 159 } 160 }); 161 }); 162 163 observer.observe(element, { 164 attributes: true, 165 attributeFilter: ['disabled'], 166 }); 167 168 element.__chosenDisabledObserver = observer; 169 }, 170 171 /** 172 * Filter out elements that should not be converted into Chosen. 173 * 174 * @param {HTMLElement} element 175 * The element. 176 * 177 * @return {boolean} 178 * TRUE if the element should stay, FALSE otherwise. 179 */ 180 filterElements: function(element) { 181 // Remove elements that should be ignored completely. 182 if (element.matches(this.settings.ignoreSelector)) { 183 return false; 184 } 185 186 // Zero value means no minimum. 187 const minOptions = element.hasAttribute('multiple') ? this.settings.minimum_multiple : this.settings.minimum_single; 188 return !minOptions || element.querySelectorAll('option').length >= minOptions; 189 }, 190 191 /** 192 * Checks if the element is visible. 193 * 194 * @param {HTMLElement} el 195 * A DOM element to check visibility for. 196 * @return {boolean} 197 * True if the element is visible, false otherwise. 198 */ 199 isVisible: function(el) { 200 return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); 201 }, 202 203 /** 204 * Splits a selector string by top-level commas only. 205 * 206 * @param {string} selector 207 * A CSS selector string. 208 * @return {Array} 209 * An array of selector parts. 210 */ 211 splitSelectors: function(selector) { 212 const result = []; 213 let current = ''; 214 let depth = 0; 215 216 for (let i = 0; i < selector.length; i++) { 217 const char = selector[i]; 218 219 if (char === '[' || char === '(') { 220 depth++; 221 } 222 else if (char === ']' || char === ')') { 223 depth = Math.max(depth - 1, 0); 224 } 225 226 if (char === ',' && depth === 0) { 227 if (current.trim()) { 228 result.push(current.trim()); 229 } 230 current = ''; 231 } 232 else { 233 current += char; 234 } 235 } 236 237 if (current.trim()) { 238 result.push(current.trim()); 239 } 240 241 return result; 242 }, 243 244 /** 245 * Parses the selector to handle :visible manually. 246 * 247 * @param {string} selector 248 * A CSS selector string that may contain :visible. 249 * @return {Array} 250 * An array of elements that match the selector and are visible (if :visible is present). 251 */ 252 getVisibleElements: function(context, selector) { 253 const selectors = this.splitSelectors(selector); 254 let elements = []; 255 256 selectors.forEach(s => { 257 const hasVisibleFilter = s.includes(':visible'); 258 const cleanSelector = s.replace(':visible', '').trim(); 259 260 if (!cleanSelector) { 261 return; 262 } 263 264 let selectedElements; 265 try { 266 selectedElements = Array.from(context.querySelectorAll(cleanSelector)); 267 } catch (error) { 268 return; 269 } 270 271 if (hasVisibleFilter) { 272 selectedElements = selectedElements.filter(this.isVisible); 273 } 274 275 elements = elements.concat(selectedElements); 276 }); 277 278 return elements; 279 }, 280 281 /** 282 * Retrieves the elements that should be converted into Chosen instances. 283 * 284 * @param {HTMLElement|Document} context 285 * A DOM Element or Document to use as context. 286 * @param {string} [selector] 287 * A selector to use, defaults to the default selector in the settings. 288 */ 289 getElements: function(context, selector) { 290 context = context || document; 291 let elements = this.getVisibleElements(context, selector || this.settings.selector); 292 293 // Remove elements that should not be converted into Chosen. 294 elements = elements.filter(function(element) { 295 return this.filterElements(element); 296 }.bind(this)); 297 298 // Add elements that have explicitly opted in to Chosen. 299 const optedInElements = Array.from(context.querySelectorAll(this.settings.optedInSelector)); 300 301 // Combine elements and opted-in elements, avoiding duplicates.
302 optedInElements.forEach(function(element) { 303 if (!elements.includes(element)) { 304 elements.push(element); 305 } 306 }); 307 308 return elements; 309 }, 310 311 /** 312 * Retrieves options used to create a Chosen instance based on an element. 313 * 314 * @param {HTMLElement} element 315 * The element to process. 316 * 317 * @return {Object} 318 * The options object used to instantiate a Chosen instance with. 319 */ 320 getElementOptions: function(element) { 321 const options = Object.assign({}, this.settings.options, this.getElementOverriddenOptions(element)); 322 let dimension; 323 let width; 324 325 // The width default option is considered the minimum width, so this 326 // must be evaluated for every option. 327 if (this.settings.minimum_width > 0) { 328 // Given we need to manage settings as both percentage and pixel widths, 329 // we need to handle width calculations separately. 330 if (this.settings.use_relative_width) { 331 dimension = '%'; 332 const elementWidth = element.getBoundingClientRect().width; 333 const parentWidth = element.parentNode.getBoundingClientRect().width; 334 width = ((elementWidth / parentWidth) * 100).toPrecision(5); 335 } else { 336 dimension = 'px'; 337 width = element.getBoundingClientRect().width; 338 } 339 340 if (width < this.settings.minimum_width) { 341 options.width = this.settings.minimum_width + dimension; 342 } else { 343 options.width = width + dimension; 344 } 345 } 346 347 // Some field widgets have cardinality, so we must respect that. 348 // @see \Drupal\chosen\ChosenFormRender::preRenderSelect() 349 const cardinality = element.dataset.cardinality; 350 if (element.hasAttribute('multiple') && cardinality) { 351 options.max_selected_options = parseInt(cardinality); 352 } 353 354 return options; 355 }, 356 357 /** 358 * Retrieves element-specific options used to override global defaults. 359 * 360 * @param {HTMLElement} element 361 * The element to process. 362 * 363 * @return {Object} 364 * The options object with per-element overrides. 365 */ 366 getElementOverriddenOptions: function(element) { 367 const overriddenOptions = {}; 368 369 const noResultsText = element.getAttribute('data-no_results_text'); 370 if (noResultsText) { 371 overriddenOptions.no_results_text = noResultsText; 372 } 373 374 const searchContains = element.getAttribute('data-search_contains'); 375 if (searchContains === '1') { 376 overriddenOptions.search_contains = true; 377 } 378 else if (searchContains === '2') { 379 overriddenOptions.search_contains = false; 380 } 381 382 return overriddenOptions; 383 }, 384 385 /** 386 * Retrieves the settings passed from Drupal. 387 * 388 * @param {Object} [settings] 389 * Passed Drupal settings object, if any. 390 */ 391 getSettings: function(settings) { 392 // Deep merge the settings 393 return this.deepMerge({}, this.settings, (settings && settings.chosen) || drupalSettings.chosen || {}); 394 }, 395 396 /** 397 * Deep merges multiple objects into the first object. 398 * This function is used instead of Object.assign to handle nested objects. 399 * 400 * @param {Object} target 401 * The target object to merge properties into. 402 * @param {...Object} sources 403 * The source objects from which to copy properties. 404 * @return {Object} 405 * The target object. 406 */ 407 deepMerge: function(target, ...sources) { 408 if (!sources.length) return target; 409 const source = sources.shift(); 410 411 if (typeof target !== 'object' || typeof source !== 'object') { 412 return target; 413 } 414 415 for (const key in source) { 416 if (source.hasOwnProperty(key)) { 417 if (typeof source[key] === 'object') { 418 if (!target[key]) Object.assign(target, { [key]: {} }); 419 this.deepMerge(target[key], source[key]); 420 } else { 421 Object.assign(target, { [key]: source[key] }); 422 } 423 } 424 } 425 426 return this.deepMerge(target, ...sources); 427 }, 428 429 }; 430 431})(Drupal, drupalSettings, once);
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.