| 1 |
/** |
| 2 |
* Searchable City/County multi-select. |
| 3 |
* |
| 4 |
* The field stays a native <select multiple> listbox showing the full list (as |
| 5 |
* it was before). Three enhancements: |
| 6 |
* - a text input above it filters the options live; |
| 7 |
* - a plain click toggles an option, so multiple items can be picked without |
| 8 |
* holding Ctrl/Cmd (the native listbox default); |
| 9 |
* - the currently selected items are mirrored as removable chips above the list. |
| 10 |
* |
| 11 |
* The underlying <select multiple> is the source of truth — it submits the same |
| 12 |
* name[]-array payload and no save logic changes. |
| 13 |
*/ |
| 14 |
( function () { |
| 15 |
'use strict'; |
| 16 |
|
| 17 |
/** |
| 18 |
* Find the native <select> that a search input controls (its next element). |
| 19 |
* |
| 20 |
* @param {Element} input The .mlsimport-select-search text input. |
| 21 |
* @return {HTMLSelectElement|null} |
| 22 |
*/ |
| 23 |
function selectFor( input ) { |
| 24 |
var node = input.nextElementSibling; |
| 25 |
while ( node && 'SELECT' !== node.tagName ) { |
| 26 |
node = node.nextElementSibling; |
| 27 |
} |
| 28 |
return node; |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Find the chips container that mirrors a given <select> (a preceding sibling). |
| 33 |
* |
| 34 |
* @param {HTMLSelectElement} select The searchable select. |
| 35 |
* @return {Element|null} |
| 36 |
*/ |
| 37 |
function chipsFor( select ) { |
| 38 |
var node = select.previousElementSibling; |
| 39 |
while ( node && ! node.classList.contains( 'mlsimport-selected-chips' ) ) { |
| 40 |
node = node.previousElementSibling; |
| 41 |
} |
| 42 |
return node; |
| 43 |
} |
| 44 |
|
| 45 |
/** |
| 46 |
* Show only options whose label contains the typed term. |
| 47 |
* |
| 48 |
* @param {Element} input The search input that fired. |
| 49 |
*/ |
| 50 |
function filter( input ) { |
| 51 |
var select = selectFor( input ); |
| 52 |
if ( ! select ) { |
| 53 |
return; |
| 54 |
} |
| 55 |
var term = input.value.toLowerCase(); |
| 56 |
var options = select.options; |
| 57 |
for ( var i = 0; i < options.length; i++ ) { |
| 58 |
var match = -1 !== options[ i ].text.toLowerCase().indexOf( term ); |
| 59 |
options[ i ].hidden = ! match; |
| 60 |
options[ i ].style.display = match ? '' : 'none'; |
| 61 |
} |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* Rebuild the chip row from the select's current selection. |
| 66 |
* |
| 67 |
* @param {HTMLSelectElement} select The searchable select. |
| 68 |
*/ |
| 69 |
function renderChips( select ) { |
| 70 |
var box = chipsFor( select ); |
| 71 |
if ( ! box ) { |
| 72 |
return; |
| 73 |
} |
| 74 |
box.innerHTML = ''; |
| 75 |
var chosen = select.selectedOptions; |
| 76 |
for ( var i = 0; i < chosen.length; i++ ) { |
| 77 |
var option = chosen[ i ]; |
| 78 |
|
| 79 |
var chip = document.createElement( 'span' ); |
| 80 |
chip.className = 'mlsimport-chip'; |
| 81 |
chip.setAttribute( 'data-value', option.value ); |
| 82 |
|
| 83 |
var label = document.createElement( 'span' ); |
| 84 |
label.className = 'mlsimport-chip-label'; |
| 85 |
label.textContent = option.text; |
| 86 |
|
| 87 |
var remove = document.createElement( 'button' ); |
| 88 |
remove.type = 'button'; |
| 89 |
remove.className = 'mlsimport-chip-remove'; |
| 90 |
remove.setAttribute( 'aria-label', 'Remove ' + option.text ); |
| 91 |
remove.textContent = '×'; |
| 92 |
|
| 93 |
chip.appendChild( label ); |
| 94 |
chip.appendChild( remove ); |
| 95 |
box.appendChild( chip ); |
| 96 |
} |
| 97 |
} |
| 98 |
|
| 99 |
/** Render chips for every searchable select on the page. */ |
| 100 |
function renderAll() { |
| 101 |
var selects = document.querySelectorAll( 'select.mlsimport-searchable-select' ); |
| 102 |
for ( var i = 0; i < selects.length; i++ ) { |
| 103 |
renderChips( selects[ i ] ); |
| 104 |
} |
| 105 |
} |
| 106 |
|
| 107 |
document.addEventListener( 'input', function ( event ) { |
| 108 |
var target = event.target; |
| 109 |
if ( target && target.classList && target.classList.contains( 'mlsimport-select-search' ) ) { |
| 110 |
filter( target ); |
| 111 |
} |
| 112 |
} ); |
| 113 |
|
| 114 |
// Plain click toggles an option — no Ctrl/Cmd needed for multi-select. |
| 115 |
document.addEventListener( 'mousedown', function ( event ) { |
| 116 |
var option = event.target; |
| 117 |
if ( ! option || 'OPTION' !== option.tagName ) { |
| 118 |
return; |
| 119 |
} |
| 120 |
var select = option.closest( 'select.mlsimport-searchable-select' ); |
| 121 |
if ( ! select ) { |
| 122 |
return; |
| 123 |
} |
| 124 |
// Block the native "replace selection" behaviour, then toggle. |
| 125 |
event.preventDefault(); |
| 126 |
option.selected = ! option.selected; |
| 127 |
var scroll = select.scrollTop; |
| 128 |
select.focus(); |
| 129 |
select.scrollTop = scroll; |
| 130 |
select.dispatchEvent( new Event( 'change', { bubbles: true } ) ); |
| 131 |
} ); |
| 132 |
|
| 133 |
// Removing a chip deselects its option in the underlying select. |
| 134 |
document.addEventListener( 'click', function ( event ) { |
| 135 |
var button = event.target; |
| 136 |
if ( ! button || ! button.classList || ! button.classList.contains( 'mlsimport-chip-remove' ) ) { |
| 137 |
return; |
| 138 |
} |
| 139 |
var box = button.closest( '.mlsimport-selected-chips' ); |
| 140 |
var chip = button.closest( '.mlsimport-chip' ); |
| 141 |
if ( ! box || ! chip ) { |
| 142 |
return; |
| 143 |
} |
| 144 |
var select = box.nextElementSibling; |
| 145 |
while ( select && ! ( 'SELECT' === select.tagName && select.classList.contains( 'mlsimport-searchable-select' ) ) ) { |
| 146 |
select = select.nextElementSibling; |
| 147 |
} |
| 148 |
if ( ! select ) { |
| 149 |
return; |
| 150 |
} |
| 151 |
var value = chip.getAttribute( 'data-value' ); |
| 152 |
var options = select.options; |
| 153 |
for ( var i = 0; i < options.length; i++ ) { |
| 154 |
if ( options[ i ].value === value ) { |
| 155 |
options[ i ].selected = false; |
| 156 |
break; |
| 157 |
} |
| 158 |
} |
| 159 |
select.dispatchEvent( new Event( 'change', { bubbles: true } ) ); |
| 160 |
} ); |
| 161 |
|
| 162 |
// Keep chips in sync with any selection change (toggle, Ctrl+click, removal). |
| 163 |
document.addEventListener( 'change', function ( event ) { |
| 164 |
var select = event.target; |
| 165 |
if ( select && 'SELECT' === select.tagName && select.classList && |
| 166 |
select.classList.contains( 'mlsimport-searchable-select' ) ) { |
| 167 |
renderChips( select ); |
| 168 |
} |
| 169 |
} ); |
| 170 |
|
| 171 |
if ( 'loading' === document.readyState ) { |
| 172 |
document.addEventListener( 'DOMContentLoaded', renderAll ); |
| 173 |
} else { |
| 174 |
renderAll(); |
| 175 |
} |
| 176 |
}() ); |
| 177 |
|