diff options
Diffstat (limited to 'toolkit/components/narrate/VoiceSelect.sys.mjs')
-rw-r--r-- | toolkit/components/narrate/VoiceSelect.sys.mjs | 294 |
1 files changed, 294 insertions, 0 deletions
diff --git a/toolkit/components/narrate/VoiceSelect.sys.mjs b/toolkit/components/narrate/VoiceSelect.sys.mjs new file mode 100644 index 0000000000..06def29ef0 --- /dev/null +++ b/toolkit/components/narrate/VoiceSelect.sys.mjs @@ -0,0 +1,294 @@ +/* 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 = `<button class="select-toggle" aria-controls="voice-options"> + <span class="label">${label}</span> <span class="current-voice"></span> + </button> + <div class="options" id="voice-options" role="listbox"></div>`; + + 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 : ""; + }, +}; |