1/** 2 * wf-select â accessible replacement for the Selectric jQuery plugin. 3 * 4 * WHY THIS EXISTS (task 27681912) 5 * Selectric hid the real <select> off-screen and painted a <div> lookalike that 6 * contained zero ARIA attributes, so the people search filters were unusable 7 * with a screen reader (options announced as "blank", no role/state, broken 8 * focus). The underlying markup â <label for>, <select id>, <optgroup> â was 9 * already correct; only the widget on top was not. 10 * 11 * APPROACH 12 * This builds the SAME DOM and class names Selectric produced, so every 13 * existing CSS rule keeps applying and nothing changes visually â including the 14 * three-column Practice Area panel, which is just `column-count:3` on the 15 * options <ul>. On top of that structure it implements the ARIA Authoring 16 * Practices "select-only combobox" pattern properly. 17 * 18 * The native <select> stays in the DOM as the value holder. It is taken out of 19 * the tab order and hidden from assistive tech (the combobox speaks for it), 20 * and every change is written back to it and announced with a `change` event, 21 * so existing filter code that reads `.val()` / `option:selected` is unaffected. 22 * 23 * Markup produced (matches Selectric exactly): 24 * 25 * div.selectric-wrapper.selectric-<each class of the select> 26 * div.selectric-hide-select > select 27 * div.selectric > span.label + <arrow markup> 28 * div.selectric-items > div.selectric-scroll > ul 29 * ul.selectric-group > li.selectric-group-label + li[data-index] 30 * li[data-index] 31 * 32 * State classes on the wrapper: selectric-open / -focus / -hover / -disabled. 33 */ 34(function (window, document) { 35 "use strict"; 36 37 var PREFIX = "selectric"; 38 var uid = 0; 39 40 var KEY = { 41 ENTER: "Enter", 42 SPACE: " ", 43 ESC: "Escape", 44 UP: "ArrowUp", 45 DOWN: "ArrowDown", 46 HOME: "Home", 47 END: "End", 48 TAB: "Tab" 49 }; 50 51 /** Normalise legacy key names so older browsers behave. */ 52 function keyName(event) { 53 var k = event.key; 54 if (k === "Esc") { return KEY.ESC; } 55 if (k === "Up") { return KEY.UP; } 56 if (k === "Down") { return KEY.DOWN; } 57 if (k === "Spacebar") { return KEY.SPACE; } 58 return k; 59 } 60 61 function el(tag, className, attrs) { 62 var node = document.createElement(tag); 63 if (className) { node.className = className; } 64 if (attrs) { 65 Object.keys(attrs).forEach(function (name) { 66 node.setAttribute(name, attrs[name]); 67 }); 68 } 69 return node; 70 } 71 72 /** 73 * Fire `change` so existing jQuery handlers (the AJAX filters) still run. 74 * jQuery does not observe natively dispatched events on elements it has 75 * delegated handlers for in every case, so prefer jQuery when present. 76 */ 77 function fireChange(select) { 78 if (window.jQuery) { 79 window.jQuery(select).trigger("change"); 80 } else { 81 select.dispatchEvent(new Event("change", { bubbles: true })); 82 } 83 } 84 85 function WfSelect(select, options) { 86 this.select = select; 87 this.options = options || {}; 88 this.id = "wf-select-" + (++uid); 89 this.isOpen = false; 90 this.activeIndex = -1; 91 this.typeBuffer = ""; 92 this.typeTimer = null; 93 this.build(); 94 this.bind(); 95 select.wfSelect = this; 96 } 97 98 WfSelect.prototype.build = function () { 99 var select = this.select; 100 101 // Mirror Selectric's wrapper classes: "selectric-wrapper" plus each class of 102 // the select, each prefixed. e.g. .selectric-practice_area_select â the 103 // theme's CSS targets these. 104 var mirrored = (select.className || "").split(/\s+/).filter(Boolean) 105 .map(function (c) { return PREFIX + "-" + c; }).join(" "); 106 107 this.wrapper = el("div", (PREFIX + "-wrapper " + mirrored).trim()); 108 this.hideSelect = el("div", PREFIX + "-hide-select"); 109 this.control = el("div", PREFIX); 110 this.label = el("span", "label"); 111 this.items = el("div", PREFIX + "-items"); 112 this.scroll = el("div", PREFIX + "-scroll"); 113 this.list = el("ul", null, { id: this.id + "-list", role: "listbox" }); 114 115 select.parentNode.insertBefore(this.wrapper, select); 116 this.hideSelect.appendChild(select); 117 118 this.control.appendChild(this.label); 119 if (this.options.arrowMarkup) { 120 this.control.insertAdjacentHTML("beforeend", this.options.arrowMarkup); 121 } 122 123 this.scroll.appendChild(this.list); 124 this.items.appendChild(this.scroll); 125 this.wrapper.appendChild(this.hideSelect); 126 this.wrapper.appendChild(this.control); 127 this.wrapper.appendChild(this.items); 128 129 // The combobox speaks for the select; the select keeps the value. 130 select.setAttribute("tabindex", "-1"); 131 select.setAttribute("aria-hidden", "true"); 132 133 this.control.setAttribute("role", "combobox"); 134 this.control.setAttribute("tabindex", select.disabled ? "-1" : "0"); 135 this.control.setAttribute("aria-haspopup", "listbox"); 136 this.control.setAttribute("aria-expanded", "false"); 137 this.control.setAttribute("aria-controls", this.id + "-list"); 138 this.control.id = this.id + "-control"; 139 140 this.labelledBy(); 141 this.populate(); 142 143 if (select.disabled) { 144 this.wrapper.classList.add(PREFIX + "-disabled"); 145 this.control.setAttribute("aria-disabled", "true"); 146 } 147 }; 148 149 /** Point the combobox at the <label for="â¦"> that already exists. */ 150 WfSelect.prototype.labelledBy = function () { 151 var select = this.select; 152 var label = null; 153 154 if (select.id) { 155 label = document.querySelector('label[for="' + select.id + '"]'); 156 } 157 if (!label) { 158 label = select.closest ? select.closest("label") : null; 159 } 160 161 if (label) { 162 if (!label.id) { label.id = this.id + "-label"; } 163 this.control.setAttribute("aria-labelledby", label.id); 164 } else if (select.getAttribute("aria-label")) { 165 this.control.setAttribute("aria-label", select.getAttribute("aria-label")); 166 } 167 }; 168 169 /** Rebuild the option list from the <select>. */ 170 WfSelect.prototype.populate = function () { 171 var self = this; 172 var index = 0; 173 174 this.list.innerHTML = ""; 175 this.optionEls = []; 176 this.optionNodes = []; 177 178 function addOption(option, container) { 179 var li = el("li", null, { 180 "data-index": String(index), 181 id: self.id + "-opt-" + index, 182 role: "option", 183 "aria-selected": option.selected ? "true" : "false" 184 }); 185 li.textContent = option.textContent; 186 187 var classes = []; 188 if (option.className) { classes.push(option.className); } 189 if (option.disabled) { classes.push("disabled"); } 190 if (option.selected) { classes.push("selected"); } 191 if (classes.length) { li.className = classes.join(" "); } 192 if (option.disabled) { li.setAttribute("aria-disabled", "true"); } 193 194 container.appendChild(li); 195 self.optionEls.push(option); 196 self.optionNodes.push(li); 197 index++; 198 } 199 200 Array.prototype.forEach.call(this.select.children, function (child) { 201 if (child.tagName === "OPTGROUP") { 202 // Selectric nested a <ul class="selectric-group"> here; the CSS
203 // (including the alphabet headers) depends on that shape. 204 var group = el("ul", PREFIX + "-group" + 205 (child.disabled ? " disabled" : "") + 206 (child.className ? " " + child.className : ""), 207 { role: "group" }); 208 var groupLabel = el("li", PREFIX + "-group-label", { role: "presentation" }); 209 groupLabel.textContent = child.label; 210 group.setAttribute("aria-label", child.label); 211 group.appendChild(groupLabel); 212 Array.prototype.forEach.call(child.children, function (option) { 213 addOption(option, group); 214 }); 215 self.list.appendChild(group); 216 } else if (child.tagName === "OPTION") { 217 addOption(child, self.list); 218 } 219 }); 220 221 if (this.optionNodes.length) { 222 this.optionNodes[this.optionNodes.length - 1].classList.add("last"); 223 } 224 225 this.syncLabel(); 226 }; 227 228 WfSelect.prototype.syncLabel = function () { 229 var selected = this.select.options[this.select.selectedIndex]; 230 this.label.textContent = selected ? selected.textContent : ""; 231 }; 232 233 /** Options the user can actually reach (CSS hides the placeholder row). */ 234 WfSelect.prototype.navigable = function () { 235 var out = []; 236 this.optionNodes.forEach(function (node, i) { 237 if (node.offsetParent !== null && !node.hasAttribute("aria-disabled")) { 238 out.push(i); 239 } 240 }); 241 return out; 242 }; 243 244 WfSelect.prototype.open = function () { 245 if (this.isOpen || this.select.disabled) { return; } 246 this.isOpen = true; 247 this.wrapper.classList.add(PREFIX + "-open"); 248 this.control.setAttribute("aria-expanded", "true"); 249 250 // Start on the current selection if it is reachable (the placeholder row 251 // is hidden by CSS), otherwise on the first option the user can see. 252 var reachable = this.navigable(); 253 var current = reachable.indexOf(this.select.selectedIndex) !== -1 254 ? this.select.selectedIndex 255 : (reachable.length ? reachable[0] : -1); 256 this.setActive(current); 257 }; 258 259 WfSelect.prototype.close = function (refocus) { 260 if (!this.isOpen) { return; } 261 this.isOpen = false; 262 this.wrapper.classList.remove(PREFIX + "-open"); 263 this.control.setAttribute("aria-expanded", "false"); 264 this.control.removeAttribute("aria-activedescendant"); 265 this.activeIndex = -1; 266 if (refocus) { this.control.focus(); } 267 }; 268 269 WfSelect.prototype.setActive = function (index) { 270 if (index < 0 || index >= this.optionNodes.length) { return; } 271 var node = this.optionNodes[index]; 272 this.optionNodes.forEach(function (n) { n.classList.remove("highlighted"); }); 273 node.classList.add("highlighted"); 274 this.activeIndex = index; 275 this.control.setAttribute("aria-activedescendant", node.id); 276 277 // keep the active row in view inside .selectric-scroll 278 var top = node.offsetTop; 279 var bottom = top + node.offsetHeight; 280 if (top < this.scroll.scrollTop) { 281 this.scroll.scrollTop = top; 282 } else if (bottom > this.scroll.scrollTop + this.scroll.clientHeight) { 283 this.scroll.scrollTop = bottom - this.scroll.clientHeight; 284 } 285 }; 286 287 WfSelect.prototype.move = function (delta) { 288 var reachable = this.navigable(); 289 if (!reachable.length) { return; } 290 var pos = reachable.indexOf(this.activeIndex); 291 if (pos === -1) { pos = delta > 0 ? -1 : reachable.length; } 292 var next = Math.min(Math.max(pos + delta, 0), reachable.length - 1); 293 this.setActive(reachable[next]); 294 }; 295 296 WfSelect.prototype.selectIndex = function (index, announce) { 297 if (index < 0 || index >= this.optionEls.length) { return; } 298 var option = this.optionEls[index]; 299 if (option.disabled) { return; } 300 301 var changed = this.select.selectedIndex !== index; 302 this.select.selectedIndex = index; 303
304 this.optionNodes.forEach(function (n, i) { 305 n.classList.toggle("selected", i === index); 306 n.setAttribute("aria-selected", i === index ? "true" : "false"); 307 }); 308 309 this.syncLabel(); 310 if (changed && announce !== false) { fireChange(this.select); } 311 }; 312 313 WfSelect.prototype.typeAhead = function (char) { 314 var self = this; 315 clearTimeout(this.typeTimer); 316 this.typeBuffer += char.toLowerCase(); 317 this.typeTimer = setTimeout(function () { self.typeBuffer = ""; }, 600); 318 319 var reachable = this.navigable(); 320 var start = reachable.indexOf(this.activeIndex); 321 var ordered = reachable.slice(start + 1).concat(reachable.slice(0, start + 1)); 322 323 for (var i = 0; i < ordered.length; i++) { 324 var idx = ordered[i]; 325 var text = (this.optionNodes[idx].textContent || "").trim().toLowerCase(); 326 if (text.indexOf(this.typeBuffer) === 0) { 327 if (this.isOpen) { 328 this.setActive(idx); 329 } else { 330 this.selectIndex(idx); 331 } 332 return; 333 } 334 } 335 }; 336 337 WfSelect.prototype.bind = function () { 338 var self = this; 339 340 this.control.addEventListener("click", function () { 341 if (self.isOpen) { self.close(); } else { self.open(); } 342 }); 343 344 this.control.addEventListener("focus", function () { 345 self.wrapper.classList.add(PREFIX + "-focus"); 346 }); 347 348 this.control.addEventListener("blur", function () { 349 self.wrapper.classList.remove(PREFIX + "-focus"); 350 self.close(); 351 }); 352 353 this.wrapper.addEventListener("mouseenter", function () { 354 self.wrapper.classList.add(PREFIX + "-hover"); 355 }); 356 this.wrapper.addEventListener("mouseleave", function () { 357 self.wrapper.classList.remove(PREFIX + "-hover"); 358 }); 359 360 this.list.addEventListener("mousedown", function (event) { 361 // mousedown, so the control does not blur before we handle it 362 event.preventDefault(); 363 }); 364 365 this.list.addEventListener("click", function (event) { 366 var li = event.target.closest("li[data-index]"); 367 if (!li) { return; } 368 self.selectIndex(parseInt(li.getAttribute("data-index"), 10)); 369 self.close(true); 370 }); 371 372 this.control.addEventListener("keydown", function (event) { 373 var key = keyName(event); 374 375 if (key === KEY.TAB) { 376 self.close(); 377 return; 378 } 379 380 if (key === KEY.ESC) { 381 if (self.isOpen) { 382 event.preventDefault(); 383 event.stopPropagation(); 384 self.close(); 385 } 386 return; 387 } 388 389 if (key === KEY.DOWN || key === KEY.UP) { 390 event.preventDefault(); 391 if (!self.isOpen) { self.open(); return; } 392 self.move(key === KEY.DOWN ? 1 : -1); 393 return; 394 } 395 396 if (key === KEY.HOME || key === KEY.END) { 397 if (!self.isOpen) { return; } 398 event.preventDefault(); 399 var reachable = self.navigable(); 400 if (reachable.length) { 401 self.setActive(key === KEY.HOME ? reachable[0] : reachable[reachable.length - 1]); 402 } 403 return; 404 } 405 406 if (key === KEY.ENTER || key === KEY.SPACE) { 407 event.preventDefault(); 408 if (!self.isOpen) { 409 self.open(); 410 } else { 411 if (self.activeIndex !== -1) { self.selectIndex(self.activeIndex); } 412 self.close(); 413 } 414 return; 415 } 416 417 if (key && key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { 418 self.typeAhead(key); 419 } 420 }); 421 422 // Outside click closes, matching the old behaviour. 423 document.addEventListener("click", function (event) { 424 if (self.isOpen && !self.wrapper.contains(event.target)) { self.close(); } 425 }); 426 427 // If other code sets the value programmatically, follow it. 428 this.select.addEventListener("change", function () {
429 self.optionNodes.forEach(function (n, i) { 430 var on = i === self.select.selectedIndex; 431 n.classList.toggle("selected", on); 432 n.setAttribute("aria-selected", on ? "true" : "false"); 433 }); 434 self.syncLabel(); 435 }); 436 }; 437 438 /** Re-read the <select> after its options or value changed. */ 439 WfSelect.prototype.refresh = function () { 440 this.populate(); 441 }; 442 443 function init(selector, options) { 444 Array.prototype.forEach.call(document.querySelectorAll(selector), function (select) { 445 if (!select.wfSelect) { new WfSelect(select, options); } 446 }); 447 } 448 449 window.wfSelect = { 450 init: init, 451 refresh: function (selector) { 452 Array.prototype.forEach.call(document.querySelectorAll(selector), function (select) { 453 if (select.wfSelect) { select.wfSelect.refresh(); } 454 }); 455 } 456 }; 457 458 document.addEventListener("DOMContentLoaded", function () { 459 init(".selectric_field", { 460 arrowMarkup: '<b class="button"><svg width="12" height="7" viewBox="0 0 12 7" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L6 6L11 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></b>' 461 }); 462 init(".region_name_dropdown", { arrowMarkup: '<b class="button">▾</b>' }); 463 }); 464}(window, document));
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.