mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			294 lines
		
	
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			294 lines
		
	
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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 : "";
 | 
						|
  },
 | 
						|
};
 |