/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export function VoiceSelect(win, label) { this._winRef = Cu.getWeakReference(win); let element = win.document.createElement("div"); element.classList.add("voiceselect"); // eslint-disable-next-line no-unsanitized/property element.innerHTML = `
`; this._elementRef = Cu.getWeakReference(element); let button = this.selectToggle; button.addEventListener("click", this); button.addEventListener("keydown", this); let listbox = this.listbox; listbox.addEventListener("click", this); listbox.addEventListener("mousemove", this); listbox.addEventListener("keydown", this); listbox.addEventListener("wheel", this, true); win.addEventListener("resize", () => { this._updateDropdownHeight(); }); } VoiceSelect.prototype = { add(label, value) { let option = this._doc.createElement("button"); option.dataset.value = value; option.classList.add("option"); option.tabIndex = "-1"; option.setAttribute("role", "option"); option.textContent = label; this.listbox.appendChild(option); return option; }, addOptions(options) { let selected = null; for (let option of options) { if (option.selected) { selected = this.add(option.label, option.value); } else { this.add(option.label, option.value); } } this._select(selected || this.options[0], true); }, clear() { this.listbox.innerHTML = ""; }, toggleList(force, focus = true) { if (this.element.classList.toggle("open", force)) { if (focus) { (this.selected || this.options[0]).focus(); } this._updateDropdownHeight(true); this.listbox.setAttribute("aria-expanded", true); this._win.addEventListener("focus", this, true); } else { if (focus) { this.element.querySelector(".select-toggle").focus(); } this.listbox.setAttribute("aria-expanded", false); this._win.removeEventListener("focus", this, true); } }, handleEvent(evt) { let target = evt.target; switch (evt.type) { case "click": target = target.closest(".option, .select-toggle") || target; if (target.classList.contains("option")) { if (!target.classList.contains("selected")) { this.selected = target; } this.toggleList(false); } else if (target.classList.contains("select-toggle")) { this.toggleList(); } break; case "mousemove": this.listbox.classList.add("hovering"); break; case "keydown": if (target.classList.contains("select-toggle")) { if (evt.altKey) { this.toggleList(true); } else { this._keyDownedButton(evt); } } else { this.listbox.classList.remove("hovering"); this._keyDownedInBox(evt); } break; case "wheel": // Don't let wheel events bubble to document. It will scroll the page // and close the entire narrate dialog. evt.stopPropagation(); break; case "focus": if (!target.closest(".voiceselect")) { this.toggleList(false, false); } break; } }, _getPagedOption(option, up) { let height = elem => elem.getBoundingClientRect().height; let listboxHeight = height(this.listbox); let next = option; for (let delta = 0; delta < listboxHeight; delta += height(next)) { let sibling = up ? next.previousElementSibling : next.nextElementSibling; if (!sibling) { break; } next = sibling; } return next; }, _keyDownedButton(evt) { if (evt.altKey && (evt.key === "ArrowUp" || evt.key === "ArrowUp")) { this.toggleList(true); return; } let toSelect; switch (evt.key) { case "PageUp": case "ArrowUp": toSelect = this.selected.previousElementSibling; break; case "PageDown": case "ArrowDown": toSelect = this.selected.nextElementSibling; break; case "Home": toSelect = this.selected.parentNode.firstElementChild; break; case "End": toSelect = this.selected.parentNode.lastElementChild; break; } if (toSelect && toSelect.classList.contains("option")) { evt.preventDefault(); this.selected = toSelect; } }, _keyDownedInBox(evt) { let toFocus; let cur = this._doc.activeElement; switch (evt.key) { case "ArrowUp": toFocus = cur.previousElementSibling || this.listbox.lastElementChild; break; case "ArrowDown": toFocus = cur.nextElementSibling || this.listbox.firstElementChild; break; case "PageUp": toFocus = this._getPagedOption(cur, true); break; case "PageDown": toFocus = this._getPagedOption(cur, false); break; case "Home": toFocus = cur.parentNode.firstElementChild; break; case "End": toFocus = cur.parentNode.lastElementChild; break; case "Escape": this.toggleList(false); break; } if (toFocus && toFocus.classList.contains("option")) { evt.preventDefault(); toFocus.focus(); } }, _select(option, suppressEvent = false) { let oldSelected = this.selected; if (oldSelected) { oldSelected.removeAttribute("aria-selected"); oldSelected.classList.remove("selected"); } if (option) { option.setAttribute("aria-selected", true); option.classList.add("selected"); this.element.querySelector(".current-voice").textContent = option.textContent; } if (!suppressEvent) { let evt = this.element.ownerDocument.createEvent("Event"); evt.initEvent("change", true, true); this.element.dispatchEvent(evt); } }, _updateDropdownHeight(now) { let updateInner = () => { let winHeight = this._win.innerHeight; let listbox = this.listbox; let listboxTop = listbox.getBoundingClientRect().top; listbox.style.maxHeight = winHeight - listboxTop - 10 + "px"; }; if (now) { updateInner(); } else if (!this._pendingDropdownUpdate) { this._pendingDropdownUpdate = true; this._win.requestAnimationFrame(() => { updateInner(); delete this._pendingDropdownUpdate; }); } }, _getOptionFromValue(value) { return Array.from(this.options).find(o => o.dataset.value === value); }, get element() { return this._elementRef.get(); }, get listbox() { return this._elementRef.get().querySelector(".options"); }, get selectToggle() { return this._elementRef.get().querySelector(".select-toggle"); }, get _win() { return this._winRef.get(); }, get _doc() { return this._win.document; }, set selected(option) { this._select(option); }, get selected() { return this.element.querySelector(".options > .option.selected"); }, get options() { return this.element.querySelectorAll(".options > .option"); }, set value(value) { this._select(this._getOptionFromValue(value)); }, get value() { let selected = this.selected; return selected ? selected.dataset.value : ""; }, };