/* 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/. */

"use strict";

// This is loaded into chrome windows with the subscript loader. Wrap in
// a block to prevent accidentally leaking globals onto `window`.
{
  const { AppConstants } = ChromeUtils.importESModule(
    "resource://gre/modules/AppConstants.sys.mjs"
  );

  let imports = {};
  ChromeUtils.defineESModuleGetters(imports, {
    DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
    KeyboardLockUtils: "resource://gre/modules/KeyboardLockUtils.sys.mjs",
    ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
  });

  const DIRECTION_BACKWARD = -1;
  const DIRECTION_FORWARD = 1;

  class MozTabbox extends MozXULElement {
    constructor() {
      super();
      this._handleMetaAltArrows = AppConstants.platform == "macosx";
      this.disconnectedCallback = this.disconnectedCallback.bind(this);
    }

    connectedCallback() {
      document.addEventListener("keydown", this, { mozSystemGroup: true });
      window.addEventListener("unload", this.disconnectedCallback, {
        once: true,
      });
    }

    disconnectedCallback() {
      document.removeEventListener("keydown", this, { mozSystemGroup: true });
      window.removeEventListener("unload", this.disconnectedCallback);
    }

    set handleCtrlTab(val) {
      this.setAttribute("handleCtrlTab", val);
    }

    get handleCtrlTab() {
      return this.getAttribute("handleCtrlTab") != "false";
    }

    get tabs() {
      if (this.hasAttribute("tabcontainer")) {
        return document.getElementById(this.getAttribute("tabcontainer"));
      }
      return this.getElementsByTagNameNS(
        "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
        "tabs"
      ).item(0);
    }

    get tabpanels() {
      return this.getElementsByTagNameNS(
        "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
        "tabpanels"
      ).item(0);
    }

    set selectedIndex(val) {
      let tabs = this.tabs;
      if (tabs) {
        tabs.selectedIndex = val;
      }
      this.setAttribute("selectedIndex", val);
    }

    get selectedIndex() {
      let tabs = this.tabs;
      return tabs ? tabs.selectedIndex : -1;
    }

    set selectedTab(val) {
      if (val) {
        let tabs = this.tabs;
        if (tabs) {
          tabs.selectedItem = val;
        }
      }
    }

    get selectedTab() {
      let tabs = this.tabs;
      return tabs && tabs.selectedItem;
    }

    set selectedPanel(val) {
      if (val) {
        let tabpanels = this.tabpanels;
        if (tabpanels) {
          tabpanels.selectedPanel = val;
        }
      }
    }

    get selectedPanel() {
      let tabpanels = this.tabpanels;
      return tabpanels && tabpanels.selectedPanel;
    }

    handleEvent(event) {
      if (!event.isTrusted) {
        // Don't let untrusted events mess with tabs.
        return;
      }

      // Skip this only if something has explicitly cancelled it.
      if (event.defaultCancelled) {
        return;
      }

      // Skip if chrome code has cancelled this
      // or keyboard lock & webcontent default prevented it
      if (event.defaultPrevented) {
        return;
      }

      // Don't check if the event was already consumed because tab
      // navigation should always work for better user experience.

      const { KeyboardLockUtils, ShortcutUtils } = imports;

      const action = ShortcutUtils.getSystemActionForEvent(event);
      // If we don't have an action, don't request reply.
      if (
        action != null &&
        KeyboardLockUtils.mustWaitForKeyboardLockRequestedReply(event)
      ) {
        return;
      }

      switch (action) {
        case ShortcutUtils.CYCLE_TABS:
          Glean.browserUiInteraction.keyboard["ctrl-tab"].add(1);
          Services.prefs.setBoolPref(
            "browser.engagement.ctrlTab.has-used",
            true
          );
          if (this.tabs && this.handleCtrlTab) {
            this.tabs.advanceSelectedTab(
              event.shiftKey ? DIRECTION_BACKWARD : DIRECTION_FORWARD,
              true
            );
            event.preventDefault();
          }
          break;
        case ShortcutUtils.PREVIOUS_TAB:
          if (this.tabs) {
            this.tabs.advanceSelectedTab(DIRECTION_BACKWARD, true);
            event.preventDefault();
          }
          break;
        case ShortcutUtils.NEXT_TAB:
          if (this.tabs) {
            this.tabs.advanceSelectedTab(DIRECTION_FORWARD, true);
            event.preventDefault();
          }
          break;
      }
    }
  }

  customElements.define("tabbox", MozTabbox);

  class MozDeck extends MozXULElement {
    get isAsync() {
      return this.getAttribute("async") == "true";
    }

    connectedCallback() {
      if (this.delayConnectedCallback()) {
        return;
      }
      this._selectedPanel = null;
      this._inAsyncOperation = false;

      let selectCurrentIndex = () => {
        // Try to select the new node if any.
        let index = this.selectedIndex;
        let oldPanel = this._selectedPanel;
        this._selectedPanel = this.children.item(index) || null;
        this.updateSelectedIndex(index, oldPanel);
      };

      this._mutationObserver = new MutationObserver(records => {
        let anyRemovals = records.some(record => !!record.removedNodes.length);
        if (anyRemovals) {
          // Try to keep the current selected panel in-place first.
          let index = Array.from(this.children).indexOf(this._selectedPanel);
          if (index != -1) {
            // Try to keep the same node selected.
            this.setAttribute("selectedIndex", index);
          }
        }
        // Select the current index if needed in case mutations have made that
        // available where it wasn't before.
        if (!this._inAsyncOperation) {
          selectCurrentIndex();
        }
      });

      this._mutationObserver.observe(this, {
        childList: true,
      });

      selectCurrentIndex();
    }

    disconnectedCallback() {
      this._mutationObserver?.disconnect();
      this._mutationObserver = null;
    }

    updateSelectedIndex(
      val,
      oldPanel = this.querySelector(":scope > .deck-selected")
    ) {
      this._inAsyncOperation = false;
      if (oldPanel != this._selectedPanel) {
        oldPanel?.classList.remove("deck-selected");
        this._selectedPanel?.classList.add("deck-selected");
      }
      this.setAttribute("selectedIndex", val);
    }

    set selectedIndex(val) {
      if (val < 0 || val >= this.children.length) {
        return;
      }

      let oldPanel = this._selectedPanel;
      this._selectedPanel = this.children[val];

      this._inAsyncOperation = this.isAsync;
      if (!this._inAsyncOperation) {
        this.updateSelectedIndex(val, oldPanel);
      }

      if (this._selectedPanel != oldPanel) {
        let event = document.createEvent("Events");
        event.initEvent("select", true, true);
        this.dispatchEvent(event);
      }
    }

    get selectedIndex() {
      let indexStr = this.getAttribute("selectedIndex");
      return indexStr ? parseInt(indexStr) : 0;
    }

    set selectedPanel(val) {
      this.selectedIndex = Array.from(this.children).indexOf(val);
    }

    get selectedPanel() {
      return this._selectedPanel;
    }
  }

  customElements.define("deck", MozDeck);

  class MozTabpanels extends MozDeck {
    /**
     * Panels that are currently within an active Split View.
     *
     * @type {string[]}
     */
    #splitViewPanels = [];

    /**
     * The splitter placed in between Split View panels.
     *
     * @type {XULElement}
     */
    #splitViewSplitter = null;
    #splitterWasDragging = false;
    #splitterAriaUpdateTask = null;
    #splitViewSplitterObserver = new MutationObserver(() => {
      const splitterState = this.#splitViewSplitter.getAttribute("state");
      if (splitterState === "dragging") {
        this.#splitterWasDragging = true;
        gBrowser.activeSplitView.resetRightPanelWidth();
      } else {
        const wasDragging = this.#splitterWasDragging;
        this.#splitterWasDragging = false;
        if (wasDragging) {
          window.promiseDocumentFlushed(() =>
            this.#recordSplitViewResizeTelemetry()
          );
        }
        this.#splitterAriaUpdateTask.arm();
      }
    });

    static #SPLIT_VIEW_PANEL_EVENTS = Object.freeze([
      "click",
      "mouseover",
      "mouseout",
    ]);

    constructor() {
      super();
      this._tabbox = null;
    }

    connectedCallback() {
      super.connectedCallback();
      this.#splitterAriaUpdateTask = new imports.DeferredTask(
        () => this.updateSplitterAriaAttributes(),
        0
      );
    }

    disconnectedCallback() {
      super.disconnectedCallback();
      this.#splitViewSplitterObserver.disconnect();
      this.#splitterAriaUpdateTask.finalize();
    }

    #recordSplitViewResizeTelemetry() {
      if (!this.#splitViewPanels.length) {
        return;
      }

      const leftPanel = document.getElementById(this.#splitViewPanels[0]);
      if (!leftPanel) {
        return;
      }

      const leftWidth = leftPanel.getBoundingClientRect().width;
      const totalWidth = this.getBoundingClientRect().width;
      const widthPercentage = Math.round((leftWidth / totalWidth) * 100);

      Glean.splitview.resize.record({ width: widthPercentage });
    }

    handleEvent(e) {
      const browser =
        e.currentTarget.tagName === "browser"
          ? e.currentTarget
          : e.currentTarget.querySelector("browser");
      let elToFocus = null;
      switch (e.type) {
        case "click":
          if (e.target.tagName !== "browser") {
            elToFocus = e.target;
          }
        // falls through
        case "focus": {
          const tab = gBrowser.getTabForBrowser(browser);
          const tabstrip = this.tabbox.tabs;
          tabstrip.selectedItem = tab;
          break;
        }
        case "mouseover":
          gBrowser.appendStatusPanel(browser);
          break;
        case "mouseout":
          StatusPanel.panel.setAttribute("inactive", true);
          gBrowser.appendStatusPanel();
          break;
      }
      elToFocus?.focus();
    }

    get tabbox() {
      // Memoize the result rather than replacing this getter, so that
      // it can be reset if the parent changes.
      if (this._tabbox) {
        return this._tabbox;
      }

      return (this._tabbox = this.closest("tabbox"));
    }

    get splitViewSplitter() {
      if (!this.#splitViewSplitter) {
        this.#splitViewSplitter = this.#createSplitViewSplitter();
      }
      return this.#splitViewSplitter;
    }

    #createSplitViewSplitter() {
      const splitter = document.createXULElement("splitter");
      splitter.className = "split-view-splitter";
      splitter.setAttribute("resizebefore", "sibling");
      splitter.setAttribute("resizeafter", "none");
      splitter.setAttribute("tabindex", "0");
      splitter.setAttribute("role", "separator");
      splitter.setAttribute("data-l10n-id", "tab-splitview-splitter");
      this.#splitterWasDragging = false;
      splitter.addEventListener("command", () => {
        gBrowser.activeSplitView.resetRightPanelWidth();
        window.promiseDocumentFlushed(() =>
          this.#recordSplitViewResizeTelemetry()
        );
        this.#splitterAriaUpdateTask.arm();
      });
      this.#splitViewSplitterObserver.observe(splitter, {
        attributeFilter: ["state"],
      });
      return splitter;
    }

    async updateSplitterAriaAttributes() {
      // avoid triggering the splitter's creation here if it doesnt already exist
      const splitter = this.#splitViewSplitter;
      if (!splitter) {
        return;
      }
      // The splitter is actively controlling the size of the left/first panel
      const controlledPanel =
        this.#splitViewPanels.length &&
        document.getElementById(this.splitViewPanels[0]);
      if (controlledPanel) {
        splitter.setAttribute("aria-controls", controlledPanel.id);

        // gather the min, max and current widths to update the aria attributes
        const [containerWidth, currentWidth] =
          await window.promiseDocumentFlushed(() => [
            this.clientWidth,
            controlledPanel.clientWidth,
          ]);
        const minWidth = parseFloat(getComputedStyle(controlledPanel).minWidth);
        // We can reuse the controlled panel's minWidth to calculate maxWidth as it should be
        // the same as the 2nd panel in the splitview
        const maxWidth = containerWidth - minWidth;
        // Sometimes dragging the splitter produces a panel width attribute which exceeds
        // the max width, so lets get our own measurment. This may end up at the previous
        // frames width
        if (controlledPanel.hasAttribute("width")) {
          const storedWidth = Number(controlledPanel.getAttribute("width"));
          if (storedWidth > maxWidth) {
            controlledPanel.setAttribute("width", currentWidth);
            controlledPanel.style.width = currentWidth + "px";
          }
        }

        splitter.setAttribute("aria-valuemin", String(minWidth));
        splitter.setAttribute("aria-valuemax", String(maxWidth));
        splitter.setAttribute("aria-valuenow", String(currentWidth));
      } else {
        splitter.removeAttribute("aria-controls");
        splitter.removeAttribute("aria-valuenow");
        splitter.removeAttribute("aria-valuemin");
        splitter.removeAttribute("aria-valuemax");
      }
    }

    /**
     * nsIDOMXULRelatedElement
     */
    getRelatedElement(aTabPanelElm) {
      if (!aTabPanelElm) {
        return null;
      }

      let tabboxElm = this.tabbox;
      if (!tabboxElm) {
        return null;
      }

      let tabsElm = tabboxElm.tabs;
      if (!tabsElm) {
        return null;
      }

      // Return tab element having 'linkedpanel' attribute equal to the id
      // of the tab panel or the same index as the tab panel element.
      let tabpanelIdx = Array.prototype.indexOf.call(
        this.children,
        aTabPanelElm
      );
      if (tabpanelIdx == -1) {
        return null;
      }

      let tabElms = tabsElm.allTabs;
      let tabElmFromIndex = tabElms[tabpanelIdx];

      let tabpanelId = aTabPanelElm.id;
      if (tabpanelId) {
        for (let idx = 0; idx < tabElms.length; idx++) {
          let tabElm = tabElms[idx];
          if (tabElm.linkedPanel == tabpanelId) {
            return tabElm;
          }
        }
      }

      return tabElmFromIndex;
    }

    set splitViewPanels(newPanels) {
      for (const [i, panel] of newPanels.entries()) {
        const panelEl = document.getElementById(panel);
        panelEl?.classList.add("split-view-panel");
        panelEl?.setAttribute("column", i);
        const browser = panelEl?.querySelector("browser");
        const browserContainer = panelEl?.querySelector(".browserContainer");
        for (const eventType of MozTabpanels.#SPLIT_VIEW_PANEL_EVENTS) {
          browserContainer?.addEventListener(eventType, this);
        }
        browser?.addEventListener("focus", this);
      }
      this.#splitViewPanels = newPanels;
      this.setSplitViewActive(!!newPanels.length);
    }

    get splitViewPanels() {
      return this.#splitViewPanels;
    }

    /**
     * Remove split view attributes from tabs and their linked panels.
     *
     * @param {MozTabbrowserTab[]} tabs
     */
    removeTabsFromSplitview(tabs) {
      for (const tab of tabs) {
        let panel = tab.linkedPanel;
        const panelEl = document.getElementById(panel);
        panelEl?.classList.remove("split-view-panel");
        panelEl?.classList.remove("split-view-panel-active");
        panelEl?.removeAttribute("column");
        const browser = panelEl?.querySelector("browser");
        const browserContainer = panelEl?.querySelector(".browserContainer");

        for (const eventType of MozTabpanels.#SPLIT_VIEW_PANEL_EVENTS) {
          browserContainer?.removeEventListener(eventType, this);
        }
        browser?.removeEventListener("focus", this);
        const index = this.#splitViewPanels.indexOf(panel);

        if (index !== -1) {
          this.#splitViewPanels.splice(index, 1);
        }
      }

      this.setSplitViewActive(!!this.#splitViewPanels.length);
    }

    /**
     * Temporarily hide split view panels when switching to a non-split-view tab.
     * Preserves the split-view-panel class and column attribute so panels re-enter
     * the flex layout at their correct size when reactivated.
     *
     * @param {MozTabbrowserTab[]} tabs
     */
    suspendSplitViewPanels(tabs) {
      for (const tab of tabs) {
        const panelEl = document.getElementById(tab.linkedPanel);
        panelEl?.classList.remove("split-view-panel-active");
      }
      this.setSplitViewActive(!!this.#splitViewPanels.length);
    }

    /**
     * Updates attributes on panels such as the blue outline for active splitview tabs,
     * panel ordering and aria attributes.
     *
     * @param {boolean} updatedValue
     */
    setSplitViewActive(updatedValue) {
      const splitViewTabSelected =
        gBrowser.selectedTab.splitview && updatedValue;
      this.toggleAttribute("splitview", updatedValue);
      this.splitViewSplitter.hidden = !splitViewTabSelected;
      const selectedPanel = this.selectedPanel;

      if (splitViewTabSelected) {
        // Ensure panels are in the correct DOM order so that focus moves
        // as expected when tabbing across a splitview
        const firstPanel = document.getElementById(this.splitViewPanels[0]);
        const secondPanel = document.getElementById(this.splitViewPanels[1]);
        if (firstPanel && secondPanel) {
          // Does secondPanel follow firstPanel? Move firstPanel before secondPanel if necessary
          if (
            !(
              firstPanel.compareDocumentPosition(secondPanel) &
              Node.DOCUMENT_POSITION_FOLLOWING
            )
          ) {
            firstPanel.parentElement.moveBefore(firstPanel, secondPanel);
          }
        }
        // Ensure the splitter is in-between the panels
        if (
          firstPanel &&
          firstPanel.nextElementSibling !== this.#splitViewSplitter
        ) {
          firstPanel.after(this.#splitViewSplitter);
        }
      }
      // Ensure that selected index stays up to date, in case the splitter
      // offsets it.
      this.selectedPanel = selectedPanel;
      // Update aria attributes
      this.#splitterAriaUpdateTask.arm();
    }
  }

  MozXULElement.implementCustomInterface(MozTabpanels, [
    Ci.nsIDOMXULRelatedElement,
  ]);
  customElements.define("tabpanels", MozTabpanels);

  MozElements.MozTab = class MozTab extends MozElements.BaseText {
    static get markup() {
      return `
        <hbox class="tab-middle box-inherit" flex="1">
          <image class="tab-icon" role="presentation"></image>
          <label class="tab-text" flex="1" role="presentation"></label>
        </hbox>
      `;
    }

    constructor() {
      super();

      this.addEventListener("mousedown", this);
      this.addEventListener("keydown", this);

      this.arrowKeysShouldWrap = AppConstants.platform == "macosx";
    }

    static get inheritedAttributes() {
      return {
        ".tab-middle": "align,dir,pack,orient,selected,visuallyselected",
        ".tab-icon": "validate,src=image",
        ".tab-text": "value=label,accesskey,crop,disabled",
      };
    }

    connectedCallback() {
      if (!this._initialized) {
        this.textContent = "";
        this.appendChild(this.constructor.fragment);
        this.initializeAttributeInheritance();
        this._initialized = true;
      }
    }

    on_mousedown(event) {
      if (event.button != 0 || this.disabled) {
        return;
      }

      this.container.ariaFocusedItem = null;

      if (this == this.container.selectedItem) {
        // This tab is already selected and we will fall
        // through to mousedown behavior which sets focus on the current tab,
        // Only a click on an already selected tab should focus the tab itself.
        return;
      }

      let stopwatchid = this.container.getAttribute("stopwatchid");
      let timerId;
      if (stopwatchid) {
        timerId = Glean.browserTimings[stopwatchid].start();
      }

      // Call this before setting the 'ignorefocus' attribute because this
      // will pass on focus if the formerly selected tab was focused as well.
      this.container._selectNewTab(this);

      var isTabFocused = false;
      try {
        isTabFocused = document.commandDispatcher.focusedElement == this;
      } catch (e) {}

      // Set '-moz-user-focus' to 'ignore' so that PostHandleEvent() can't
      // focus the tab; we only want tabs to be focusable by the mouse if
      // they are already focused. After a short timeout we'll reset
      // '-moz-user-focus' so that tabs can be focused by keyboard again.
      if (!isTabFocused) {
        this.setAttribute("ignorefocus", "true");
        setTimeout(tab => tab.removeAttribute("ignorefocus"), 0, this);
      }

      if (stopwatchid) {
        Glean.browserTimings[stopwatchid].stopAndAccumulate(timerId);
      }
    }

    /**
     * @returns {"ltr"|"rtl"}
     */
    #getDirection() {
      return window.getComputedStyle(this).direction;
    }

    /**
     * @param {KeyEvent} event
     */
    on_keydown(event) {
      if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
        return;
      }

      // Handles some keyboard interactions when the active tab is in focus.
      switch (event.keyCode) {
        case KeyEvent.DOM_VK_LEFT: {
          this.container.advanceSelectedItem(
            this.#getDirection() == "ltr"
              ? DIRECTION_BACKWARD
              : DIRECTION_FORWARD,
            this.arrowKeysShouldWrap
          );
          event.preventDefault();
          break;
        }

        case KeyEvent.DOM_VK_RIGHT: {
          this.container.advanceSelectedItem(
            this.#getDirection() == "ltr"
              ? DIRECTION_FORWARD
              : DIRECTION_BACKWARD,
            this.arrowKeysShouldWrap
          );
          event.preventDefault();
          break;
        }

        case KeyEvent.DOM_VK_UP:
          this.container.advanceSelectedItem(
            DIRECTION_BACKWARD,
            this.arrowKeysShouldWrap
          );
          event.preventDefault();
          break;

        case KeyEvent.DOM_VK_DOWN:
          this.container.advanceSelectedItem(
            DIRECTION_FORWARD,
            this.arrowKeysShouldWrap
          );
          event.preventDefault();
          break;

        case KeyEvent.DOM_VK_HOME:
          this.container._selectNewTab(this.allTabs.at(0), DIRECTION_FORWARD);
          event.preventDefault();
          break;

        case KeyEvent.DOM_VK_END: {
          this.container._selectNewTab(this.allTabs.at(-1), DIRECTION_BACKWARD);
          event.preventDefault();
          break;
        }
      }
    }

    set value(val) {
      this.setAttribute("value", val);
    }

    get value() {
      return this.getAttribute("value") || "";
    }

    get container() {
      return this.closest("tabs");
    }

    // nsIDOMXULSelectControlItemElement
    get control() {
      return this.container;
    }

    get selected() {
      return this.getAttribute("selected") == "true";
    }

    set _selected(val) {
      if (val) {
        this.setAttribute("selected", "true");
        this.setAttribute("visuallyselected", "true");
      } else {
        this.removeAttribute("selected");
        this.removeAttribute("visuallyselected");
      }
    }

    /** @returns {boolean} */
    get visible() {
      return !this.hidden;
    }

    set linkedPanel(val) {
      this.setAttribute("linkedpanel", val);
    }

    get linkedPanel() {
      return this.getAttribute("linkedpanel");
    }
  };

  MozXULElement.implementCustomInterface(MozElements.MozTab, [
    Ci.nsIDOMXULSelectControlItemElement,
  ]);
  customElements.define("tab", MozElements.MozTab);

  const ARIA_FOCUSED_CLASS_NAME = "tablist-keyboard-focus";

  class TabsBase extends MozElements.BaseControl {
    constructor() {
      super();

      this.addEventListener("DOMMouseScroll", event => {
        if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) {
          if (event.detail > 0) {
            this.advanceSelectedTab(DIRECTION_FORWARD, false);
          } else {
            this.advanceSelectedTab(DIRECTION_BACKWARD, false);
          }
          event.stopPropagation();
        }
      });
    }

    // to be called from derived class connectedCallback
    baseConnect() {
      this._tabbox = null;
      this.ACTIVE_DESCENDANT_ID = `${ARIA_FOCUSED_CLASS_NAME}-${Math.trunc(
        Math.random() * 1000000
      )}`;

      if (!this.hasAttribute("orient")) {
        this.setAttribute("orient", "horizontal");
      }

      if (this.tabbox && this.tabbox.hasAttribute("selectedIndex")) {
        let selectedIndex = parseInt(this.tabbox.getAttribute("selectedIndex"));
        this.selectedIndex = selectedIndex > 0 ? selectedIndex : 0;
        return;
      }

      let children = this.allTabs;
      let length = children.length;
      for (var i = 0; i < length; i++) {
        if (children[i].getAttribute("selected") == "true") {
          this.selectedIndex = i;
          return;
        }
      }

      var value = this.value;
      if (value) {
        this.value = value;
      } else {
        this.selectedIndex = 0;
      }
    }

    /**
     * nsIDOMXULSelectControlElement
     */
    get itemCount() {
      return this.allTabs.length;
    }

    set value(val) {
      this.setAttribute("value", val);
      var children = this.allTabs;
      for (var c = children.length - 1; c >= 0; c--) {
        if (children[c].value == val) {
          this.selectedIndex = c;
          break;
        }
      }
    }

    get value() {
      return this.getAttribute("value") || "";
    }

    get tabbox() {
      if (!this._tabbox) {
        // Memoize the result in a field rather than replacing this property,
        // so that it can be reset along with the binding.
        this._tabbox = this.closest("tabbox");
      }

      return this._tabbox;
    }

    /**
     * @param {number} val
     */
    set selectedIndex(val) {
      var tab = this.getItemAtIndex(val);
      if (!tab) {
        return;
      }
      for (let otherTab of this.allTabs) {
        if (otherTab != tab && otherTab.selected) {
          otherTab._selected = false;
        }
      }
      tab._selected = true;

      this.setAttribute("value", tab.value);

      let linkedPanel = this.getRelatedElement(tab);
      if (linkedPanel) {
        this.tabbox.setAttribute("selectedIndex", val);

        // This will cause an onselect event to fire for the tabpanel
        // element.
        this.tabbox.tabpanels.selectedPanel = linkedPanel;
      }
    }

    /**
     * @returns {number}
     */
    get selectedIndex() {
      const tabs = this.allTabs;
      for (var i = 0; i < tabs.length; i++) {
        if (tabs[i].selected) {
          return i;
        }
      }
      return -1;
    }

    /**
     * @param {MozTab|null} [val]
     */
    set selectedItem(val) {
      if (val && !val.selected) {
        // The selectedIndex setter ignores invalid values
        // such as -1 if |val| isn't one of our child nodes.
        this.selectedIndex = this.getIndexOfItem(val);
      }
    }

    /**
     * @returns {MozTab|null}
     */
    get selectedItem() {
      const tabs = this.allTabs;
      for (var i = 0; i < tabs.length; i++) {
        if (tabs[i].selected) {
          return tabs[i];
        }
      }
      return null;
    }

    /**
     * @returns {MozTab[]}
     */
    get ariaFocusableItems() {
      return this.allTabs;
    }

    /**
     * @returns {number}
     */
    get ariaFocusedIndex() {
      const items = this.ariaFocusableItems;
      for (var i = 0; i < items.length; i++) {
        if (items[i].id == this.ACTIVE_DESCENDANT_ID) {
          return i;
        }
      }
      return -1;
    }

    /**
     * @param {MozTab|null} [val]
     */
    set ariaFocusedItem(val) {
      let setNewItem = val && this.ariaFocusableItems.includes(val);
      let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem);

      if (clearExistingItem) {
        let ariaFocusedItem = this.ariaFocusedItem;
        ariaFocusedItem.classList.remove(ARIA_FOCUSED_CLASS_NAME);
        ariaFocusedItem.id = "";
        this.selectedItem.removeAttribute("aria-activedescendant");
        let evt = new CustomEvent("AriaFocus");
        this.selectedItem.dispatchEvent(evt);
      }

      if (setNewItem) {
        val.id = this.ACTIVE_DESCENDANT_ID;
        val.classList.add(ARIA_FOCUSED_CLASS_NAME);
        this.selectedItem.setAttribute(
          "aria-activedescendant",
          this.ACTIVE_DESCENDANT_ID
        );
        let evt = new CustomEvent("AriaFocus");
        val.dispatchEvent(evt);
      }
    }

    /**
     * @returns {MozTab|null}
     */
    get ariaFocusedItem() {
      return document.getElementById(this.ACTIVE_DESCENDANT_ID);
    }

    /**
     * nsIDOMXULRelatedElement
     */
    getRelatedElement(aTabElm) {
      if (!aTabElm) {
        return null;
      }

      let tabboxElm = this.tabbox;
      if (!tabboxElm) {
        return null;
      }

      let tabpanelsElm = tabboxElm.tabpanels;
      if (!tabpanelsElm) {
        return null;
      }

      // Get linked tab panel by 'linkedpanel' attribute on the given tab
      // element.
      let linkedPanelId = aTabElm.linkedPanel;
      if (linkedPanelId) {
        return this.ownerDocument.getElementById(linkedPanelId);
      }

      // otherwise linked tabpanel element has the same index as the given
      // tab element.
      let tabElmIdx = this.getIndexOfItem(aTabElm);
      return tabpanelsElm.children[tabElmIdx];
    }

    /**
     * @param {MozTab} item
     * @returns {number}
     */
    getIndexOfItem(item) {
      return Array.prototype.indexOf.call(this.allTabs, item);
    }

    /**
     * @param {numb} index
     * @returns {MozTab|null}
     */
    getItemAtIndex(index) {
      return this.allTabs[index] || null;
    }

    /**
     * Find an adjacent tab.
     *
     * @param {MozTab} startTab
     *   A `<tab>` element to start searching from.
     * @param {object} opts
     * @param {number} [opts.direction=1]
     *   1 to search forward, -1 to search backward.
     * @param {boolean} [opts.wrap=false]
     *   If true, wrap around if the search reaches the end (or beginning)
     *   of the tab strip.
     * @param {boolean} [opts.startWithAdjacent=true]
     *   If true (which is the default), start searching from the next tab
     *   after (or before) `startTab`. If false, `startTab` may be returned
     *   if it passes the filter.
     * @param {function(MozTab):boolean} [opts.filter]
     *   A function to select which tabs to return.
     * @return {MozTab|null}
     *   The next `<tab>` element or, if none exists, null.
     */
    findNextTab(startTab, opts = {}) {
      let {
        direction = 1,
        wrap = false,
        startWithAdjacent = true,
        filter = () => true,
      } = opts;

      let tab = startTab;
      if (!startWithAdjacent && filter(tab)) {
        return tab;
      }

      let children = this.allTabs;
      let i = children.indexOf(tab);
      if (i < 0) {
        return null;
      }

      while (true) {
        i += direction;
        if (wrap) {
          if (i < 0) {
            i = children.length - 1;
          } else if (i >= children.length) {
            i = 0;
          }
        } else if (i < 0 || i >= children.length) {
          return null;
        }

        tab = children[i];
        if (tab == startTab) {
          return null;
        }
        if (filter(tab)) {
          return tab;
        }
      }
    }

    /**
     * @param {MozTab} aNewTab
     * @param {-1|1} [aFallbackDir]
     * @param {boolean} [aWrap]
     * @returns
     */
    _selectNewTab(aNewTab, aFallbackDir, aWrap) {
      this.ariaFocusedItem = null;

      aNewTab = this.findNextTab(aNewTab, {
        direction: aFallbackDir,
        wrap: aWrap,
        startWithAdjacent: false,
        filter: tab =>
          !tab.hidden && !tab.disabled && this._canAdvanceToTab(tab),
      });

      var isTabFocused = false;
      try {
        isTabFocused =
          document.commandDispatcher.focusedElement == this.selectedItem;
      } catch (e) {}
      this.selectedItem = aNewTab;
      if (isTabFocused) {
        aNewTab.focus();
      } else if (this.getAttribute("setfocus") != "false") {
        let selectedPanel = this.tabbox.selectedPanel;
        document.commandDispatcher.advanceFocusIntoSubtree(selectedPanel);

        // Make sure that the focus doesn't move outside the tabbox
        if (this.tabbox) {
          try {
            let el = document.commandDispatcher.focusedElement;
            while (el && el != this.tabbox.tabpanels) {
              if (el == this.tabbox || el == selectedPanel) {
                return;
              }
              el = el.parentNode;
            }
            aNewTab.focus();
          } catch (e) {}
        }
      }
    }

    _canAdvanceToTab() {
      return true;
    }

    /**
     * Selects the next visible tab in this list of tabs.
     *
     * @param {-1|1} [aDir]
     * @param {boolean} [aWrap]
     */
    advanceSelectedTab(aDir, aWrap) {
      let { ariaFocusedItem } = this;
      let startTab = ariaFocusedItem;
      if (!ariaFocusedItem || !this.allTabs.includes(ariaFocusedItem)) {
        startTab = this.selectedItem;
      }
      let newTab = null;

      // Handle keyboard navigation for a hidden tab that can be selected, like the Firefox View tab,
      // which has a random placement in this.allTabs.
      if (startTab.hidden) {
        if (aDir == 1) {
          newTab = this.allTabs.find(tab => tab.visible);
        } else {
          newTab = this.allTabs.findLast(tab => tab.visible);
        }
      } else {
        newTab = this.findNextTab(startTab, {
          direction: aDir,
          wrap: aWrap,
          filter: tab => tab.visible,
        });
      }

      if (newTab && newTab != startTab) {
        this._selectNewTab(newTab, aDir, aWrap);
      }
    }

    /**
     * Selects the next visible item in this list of items.
     *
     * This provides an extension point for code to mix non-tab items inside
     * of this tab list and be able to appropriately and logically advance to
     * the next tab or non-tab.
     *
     * @param {-1|1} [aDir]
     * @param {boolean} [aWrap]
     */
    advanceSelectedItem(aDir, aWrap) {
      this.advanceSelectedTab(aDir, aWrap);
    }

    appendItem(label, value) {
      var tab = document.createXULElement("tab");
      tab.setAttribute("label", label);
      tab.setAttribute("value", value);
      this.appendChild(tab);
      return tab;
    }
  }

  MozXULElement.implementCustomInterface(TabsBase, [
    Ci.nsIDOMXULSelectControlElement,
    Ci.nsIDOMXULRelatedElement,
  ]);

  MozElements.TabsBase = TabsBase;

  class MozTabs extends TabsBase {
    connectedCallback() {
      if (this.delayConnectedCallback()) {
        return;
      }

      let start = MozXULElement.parseXULToFragment(
        `<spacer class="tabs-left"/>`
      );
      this.insertBefore(start, this.firstChild);

      let end = MozXULElement.parseXULToFragment(
        `<spacer class="tabs-right"/>`
      );
      this.insertBefore(end, null);

      this.baseConnect();
    }

    // Accessor for tabs.  This element has spacers as the first and
    // last elements and <tab>s are everything in between.
    get allTabs() {
      let children = Array.from(this.children);
      return children.splice(1, children.length - 2);
    }

    appendChild(tab) {
      // insert before the end spacer.
      this.insertBefore(tab, this.lastChild);
    }
  }

  customElements.define("tabs", MozTabs);
}
