PageSourceSearch

https://www.williamfry.com/wp-content/themes/williamfry-2026/assets/js/wf-select.js

js williamfry.com collected 2026-10-02 04:47:11 UTC 17,715 bytes, 464 lines download raw bytes

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">&#9662;</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.