Source code
Revision control
Copy as Markdown
Other Tools
/* 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
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" aria-expanded="false" role="combobox">
<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.selectToggle.setAttribute("aria-expanded", true);
this._win.addEventListener("focus", this, true);
} else {
if (focus) {
this.element.querySelector(".select-toggle").focus();
}
this.selectToggle.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 - 32 + "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 : "";
},
};