/* 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";

{
  class PanelList extends HTMLElement {
    static get observedAttributes() {
      return ["open"];
    }

    static get fragment() {
      if (!this._template) {
        let parser = new DOMParser();
        let cssPath = "chrome://global/content/elements/panel-list.css";
        let doc = parser.parseFromString(
          `
          <template>
            <link rel="stylesheet" href=${cssPath}>
            <div class="arrow top" role="presentation"></div>
            <div class="list" role="presentation">
              <slot></slot>
            </div>
            <div class="arrow bottom" role="presentation"></div>
          </template>
        `,
          "text/html"
        );
        this._template = document.importNode(
          doc.querySelector("template"),
          true
        );
      }
      return this._template.content.cloneNode(true);
    }

    constructor() {
      super();
      this.attachShadow({ mode: "open" });
      this.shadowRoot.appendChild(this.constructor.fragment);
    }

    connectedCallback() {
      this.setAttribute("role", "menu");
    }

    attributeChangedCallback(name, oldVal, newVal) {
      if (name == "open" && newVal != oldVal) {
        if (this.open) {
          this.onShow();
        } else {
          this.onHide();
        }
      }
    }

    get open() {
      return this.hasAttribute("open");
    }

    set open(val) {
      this.toggleAttribute("open", val);
    }

    get stayOpen() {
      return this.hasAttribute("stay-open");
    }

    set stayOpen(val) {
      this.toggleAttribute("stay-open", val);
    }

    getTargetForEvent(event) {
      if (!event) {
        return null;
      }
      if (event._savedComposedTarget) {
        return event._savedComposedTarget;
      }
      if (event.composed) {
        event._savedComposedTarget =
          event.composedTarget || event.composedPath()[0];
      }
      return event._savedComposedTarget || event.target;
    }

    show(triggeringEvent, target) {
      this.triggeringEvent = triggeringEvent;
      this.lastAnchorNode =
        target || this.getTargetForEvent(this.triggeringEvent);

      this.wasOpenedByKeyboard =
        triggeringEvent &&
        (triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD ||
          triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_UNKNOWN ||
          triggeringEvent.code == "ArrowRight" ||
          triggeringEvent.code == "ArrowLeft");
      this.open = true;

      if (this.parentIsXULPanel()) {
        this.toggleAttribute("inxulpanel", true);
        let panel = this.parentElement;
        panel.hidden = false;
        // Bug 1842070 - There appears to be a race here where panel-lists
        // embedded in XUL panels won't appear during the first call to show()
        // without waiting for a mix of rAF and another tick of the event
        // loop.
        requestAnimationFrame(() => {
          setTimeout(() => {
            panel.openPopup(
              this.lastAnchorNode,
              "after_start",
              0,
              0,
              false,
              false,
              this.triggeringEvent
            );
          }, 0);
        });
      } else {
        this.toggleAttribute("inxulpanel", false);
      }
    }

    hide(triggeringEvent, { force = false } = {}, eventTarget) {
      // It's possible this is being used in an unprivileged context, in which
      // case it won't have access to Services / Services will be undeclared.
      const autohideDisabled = this.hasServices()
        ? Services.prefs.getBoolPref("ui.popup.disable_autohide", false)
        : false;

      if (autohideDisabled && !force) {
        // Don't hide if this wasn't "forced" (using escape or click in menu).
        return;
      }
      let openingEvent = this.triggeringEvent;
      this.triggeringEvent = triggeringEvent;
      this.open = false;

      if (this.parentIsXULPanel()) {
        // It's possible that we're being programattically hidden, in which
        // case, we need to hide the XUL panel we're embedded in. If, however,
        // we're being hidden because the XUL panel is being hidden, calling
        // hidePopup again on it is a no-op.
        let panel = this.parentElement;
        panel.hidePopup();
      }

      let target = eventTarget || this.getTargetForEvent(openingEvent);
      // Refocus the button that opened the menu if we have one.
      if (target && this.wasOpenedByKeyboard) {
        target.focus();
      }
    }

    toggle(triggeringEvent, target = null) {
      if (this.open) {
        this.hide(triggeringEvent, { force: true }, target);
      } else {
        this.show(triggeringEvent, target);
      }
    }

    hasServices() {
      // Safely check for Services without throwing a ReferenceError.
      return typeof Services !== "undefined";
    }

    isDocumentRTL() {
      if (this.hasServices()) {
        return Services.locale.isAppLocaleRTL;
      }
      return document.dir === "rtl";
    }

    parentIsXULPanel() {
      const XUL_NS =
        "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
      return (
        this.parentElement?.namespaceURI == XUL_NS &&
        this.parentElement?.localName == "panel"
      );
    }

    async setAlign() {
      const hostElement = this.parentElement || this.getRootNode().host;
      if (!hostElement) {
        // This could get called before we're added to the DOM.
        // Nothing to do in that case.
        return;
      }

      // Set the showing attribute to hide the panel until its alignment is set.
      this.setAttribute("showing", "true");
      // Tell the host element to hide any overflow in case the panel extends off
      // the page before the alignment is set.
      hostElement.style.overflow = "hidden";

      // Wait for a layout flush, then find the bounds.
      let {
        anchorBottom, // distance from the bottom of the anchor el to top of viewport.
        anchorLeft,
        anchorTop,
        anchorWidth,
        panelHeight,
        panelWidth,
        winHeight,
        winScrollY,
        winScrollX,
        clientWidth,
      } = await new Promise(resolve => {
        this.style.left = 0;
        this.style.top = 0;

        requestAnimationFrame(() =>
          setTimeout(() => {
            let target = this.getTargetForEvent(this.triggeringEvent);
            let anchorElement = target || hostElement;
            // It's possible this is being used in a context where windowUtils is
            // not available. In that case, fallback to using the element.
            let getBounds = el =>
              window.windowUtils
                ? window.windowUtils.getBoundsWithoutFlushing(el)
                : el.getBoundingClientRect();
            // Use y since top is reserved.
            let anchorBounds = getBounds(anchorElement);
            let panelBounds = getBounds(this);
            let clientWidth = document.scrollingElement.clientWidth;

            resolve({
              anchorBottom: anchorBounds.bottom,
              anchorHeight: anchorBounds.height,
              anchorLeft: anchorBounds.left,
              anchorTop: anchorBounds.top,
              anchorWidth: anchorBounds.width,
              panelHeight: panelBounds.height,
              panelWidth: panelBounds.width,
              winHeight: innerHeight,
              winScrollX: scrollX,
              winScrollY: scrollY,
              clientWidth,
            });
          }, 0)
        );
      });

      // If we're embedded in a XUL panel, let it handle alignment.
      if (!this.parentIsXULPanel()) {
        // Calculate the left/right alignment.
        let align;
        let leftOffset;
        let leftAlignX = anchorLeft;
        let rightAlignX = anchorLeft + anchorWidth - panelWidth;

        if (this.isDocumentRTL()) {
          // Prefer aligning on the right.
          align = rightAlignX < 0 ? "left" : "right";
        } else {
          // Prefer aligning on the left.
          align = leftAlignX + panelWidth > clientWidth ? "right" : "left";
        }
        leftOffset = align === "left" ? leftAlignX : rightAlignX;

        let bottomSpaceY = winHeight - anchorBottom;

        let valign;
        let topOffset;
        const VIEWPORT_PANEL_MIN_MARGIN = 10; // 10px ensures that the panel is not flush with the viewport.

        // Only want to valign top when there's more space between the bottom of the anchor element and the top of the viewport.
        // If there's more space between the bottom of the anchor element and the bottom of the viewport, we valign bottom.
        if (
          anchorBottom > bottomSpaceY &&
          anchorBottom + panelHeight > winHeight
        ) {
          // Never want to have a negative value for topOffset, so ensure it's at least 10px.
          topOffset = Math.max(
            anchorTop - panelHeight,
            VIEWPORT_PANEL_MIN_MARGIN
          );
          // Provide a max-height for larger elements which will provide scrolling as needed.
          this.style.maxHeight = `${anchorTop + VIEWPORT_PANEL_MIN_MARGIN}px`;
          valign = "top";
        } else {
          topOffset = anchorBottom;
          this.style.maxHeight = `${
            bottomSpaceY - VIEWPORT_PANEL_MIN_MARGIN
          }px`;
          valign = "bottom";
        }

        // Set the alignments and show the panel.
        this.setAttribute("align", align);
        this.setAttribute("valign", valign);
        hostElement.style.overflow = "";

        this.style.left = `${leftOffset + winScrollX}px`;
        this.style.top = `${topOffset + winScrollY}px`;
      }

      this.style.minWidth = this.hasAttribute("min-width-from-anchor")
        ? `${anchorWidth}px`
        : "";

      this.removeAttribute("showing");
    }

    addHideListeners() {
      if (this.hasAttribute("stay-open") && !this.lastAnchorNode?.hasSubmenu) {
        // This is intended for inspection in Storybook.
        return;
      }
      // Hide when a panel-item is clicked in the list.
      this.addEventListener("click", this);
      // Allows submenus to stopPropagation when focus is already in the menu
      this.addEventListener("keydown", this);
      // We need Escape/Tab/ArrowDown to work when opened with the mouse.
      document.addEventListener("keydown", this);
      // Hide when a click is initiated outside the panel.
      document.addEventListener("mousedown", this);
      // Hide if focus changes and the panel isn't in focus.
      document.addEventListener("focusin", this);
      // Reset or focus tracking, we treat the first focusin differently.
      this.focusHasChanged = false;
      // Hide on resize, scroll or losing window focus.
      window.addEventListener("resize", this);
      window.addEventListener("scroll", this, { capture: true });
      window.addEventListener("blur", this);
      if (this.parentIsXULPanel()) {
        this.parentElement.addEventListener("popuphidden", this);
      }
    }

    removeHideListeners() {
      this.removeEventListener("click", this);
      this.removeEventListener("keydown", this);
      document.removeEventListener("keydown", this);
      document.removeEventListener("mousedown", this);
      document.removeEventListener("focusin", this);
      window.removeEventListener("resize", this);
      window.removeEventListener("scroll", this, { capture: true });
      window.removeEventListener("blur", this);
      if (this.parentIsXULPanel()) {
        this.parentElement.removeEventListener("popuphidden", this);
      }
    }

    handleEvent(e) {
      // Ignore the event if it caused the panel to open.
      if (e == this.triggeringEvent) {
        return;
      }

      let target = this.getTargetForEvent(e);
      let inPanelList = e.composed
        ? e.composedPath().some(el => el == this)
        : e.target.closest && e.target.closest("panel-list") == this;

      switch (e.type) {
        case "resize":
        case "scroll":
          if (inPanelList) {
            break;
          }
        // Intentional fall-through
        case "blur":
        case "popuphidden":
          this.hide();
          break;
        case "click":
          if (inPanelList) {
            this.hide(undefined, { force: true });
          } else {
            // Avoid falling through to the default click handler of the parent.
            e.stopPropagation();
          }
          break;
        case "mousedown":
          // Close if there's a click started outside the panel.
          if (!inPanelList) {
            this.hide();
          }
          break;
        case "keydown":
          if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab") {
            // Ignore tabbing with a modifer other than shift.
            if (e.key === "Tab" && (e.altKey || e.ctrlKey || e.metaKey)) {
              return;
            }

            // Don't scroll the page or let the regular tab order take effect.
            e.preventDefault();

            // Prevents the host panel list from responding to these events while
            // the submenu is active.
            e.stopPropagation();

            // Keep moving to the next/previous element sibling until we find a
            // panel-item that isn't hidden.
            let moveForward =
              e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey);

            let nextItem = moveForward
              ? this.focusWalker.nextNode()
              : this.focusWalker.previousNode();

            // If the next item wasn't found, try looping to the top/bottom.
            if (!nextItem) {
              this.focusWalker.currentNode = this;
              if (moveForward) {
                nextItem = this.focusWalker.firstChild();
              } else {
                nextItem = this.focusWalker.lastChild();
              }
            }
            break;
          } else if (e.key === "Escape") {
            this.hide(undefined, { force: true });
          } else if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
            // Check if any of the children have an accesskey for this letter.
            let item = this.querySelector(
              `[accesskey="${e.key.toLowerCase()}"],
              [accesskey="${e.key.toUpperCase()}"]`
            );
            if (item) {
              item.click();
            }
          }
          break;
        case "focusin":
          if (
            this.triggeringEvent &&
            target == this.getTargetForEvent(this.triggeringEvent) &&
            !this.focusHasChanged
          ) {
            // There will be a focusin after the mousedown that opens the panel
            // using the mouse. Ignore the first focusin event if it's on the
            // triggering target.
            this.focusHasChanged = true;
          } else {
            // Just record that there was a focusin event.
            this.focusHasChanged = true;
          }
          break;
      }
    }

    /**
     * A TreeWalker that can be used to focus elements. The returned element will
     * be the element that has gained focus based on the requested movement
     * through the tree.
     *
     * Example:
     *
     *   this.focusWalker.currentNode = this;
     *   // Focus and get the first focusable child.
     *   let focused = this.focusWalker.nextNode();
     *   // Focus the second focusable child.
     *   this.focusWalker.nextNode();
     */
    get focusWalker() {
      if (!this._focusWalker) {
        this._focusWalker = document.createTreeWalker(
          this,
          NodeFilter.SHOW_ELEMENT,
          {
            acceptNode: node => {
              // No need to look at hidden nodes.
              if (node.hidden) {
                return NodeFilter.FILTER_REJECT;
              }

              // Focus the node, if it worked then this is the node we want.
              node.focus();
              if (node === node.getRootNode().activeElement) {
                return NodeFilter.FILTER_ACCEPT;
              }

              // Continue into child nodes if the parent couldn't be focused.
              return NodeFilter.FILTER_SKIP;
            },
          }
        );
      }
      return this._focusWalker;
    }
    async setSubmenuAlign() {
      const hostElement =
        this.lastAnchorNode.parentElement || this.getRootNode().host;
      // The showing attribute allows layout of the panel while remaining hidden
      // from the user until alignment is set.
      this.setAttribute("showing", "true");

      // Wait for a layout flush, then find the bounds.
      let {
        anchorLeft,
        anchorWidth,
        anchorTop,
        parentPanelTop,
        panelWidth,
        clientWidth,
      } = await new Promise(resolve => {
        requestAnimationFrame(() => {
          // It's possible this is being used in a context where windowUtils is
          // not available. In that case, fallback to using the element.
          let getBounds = el =>
            window.windowUtils
              ? window.windowUtils.getBoundsWithoutFlushing(el)
              : el.getBoundingClientRect();
          // submenu item in the parent panel list
          let anchorBounds = getBounds(this.lastAnchorNode);
          let parentPanelBounds = getBounds(hostElement);
          let panelBounds = getBounds(this);
          let clientWidth = document.scrollingElement.clientWidth;

          resolve({
            anchorLeft: anchorBounds.left,
            anchorWidth: anchorBounds.width,
            anchorTop: anchorBounds.top,
            parentPanelTop: parentPanelBounds.top,
            panelWidth: panelBounds.width,
            clientWidth,
          });
        });
      });

      let align = hostElement.getAttribute("align");

      // we use document.scrollingElement.clientWidth to exclude the width
      // of vertical scrollbars, because its inclusion can cause the submenu
      // to open to the wrong side and be overlapped by the scrollbar.
      if (
        align == "left" &&
        anchorLeft + anchorWidth + panelWidth < clientWidth
      ) {
        this.style.left = `${anchorWidth}px`;
        this.style.right = "";
      } else {
        this.style.right = `${anchorWidth}px`;
        this.style.left = "";
      }

      let topOffset =
        anchorTop -
        parentPanelTop -
        (parseFloat(window.getComputedStyle(this)?.paddingTop) || 0);
      this.style.top = `${topOffset}px`;

      this.removeAttribute("showing");
    }

    async onShow() {
      this.sendEvent("showing");
      this.addHideListeners();

      if (this.lastAnchorNode?.hasSubmenu) {
        await this.setSubmenuAlign();
      } else {
        await this.setAlign();
      }

      // Always reset this regardless of how the panel list is opened
      // so the first child will be focusable.
      this.focusWalker.currentNode = this;

      // Wait until the next paint for the alignment to be set and panel to be
      // visible.
      requestAnimationFrame(() => {
        if (this.wasOpenedByKeyboard) {
          // Focus the first focusable panel-item if opened by keyboard.
          this.focusWalker.nextNode();
        }

        this.lastAnchorNode?.setAttribute("aria-expanded", "true");

        this.sendEvent("shown");
      });
    }

    onHide() {
      requestAnimationFrame(() => {
        this.sendEvent("hidden");
        this.lastAnchorNode?.setAttribute("aria-expanded", "false");
      });
      this.removeHideListeners();
    }

    sendEvent(name, detail) {
      this.dispatchEvent(
        new CustomEvent(name, { detail, bubbles: true, composed: true })
      );
    }
  }
  customElements.define("panel-list", PanelList);

  class PanelItem extends HTMLElement {
    #initialized = false;
    #defaultSlot;

    static get observedAttributes() {
      return ["accesskey", "type"];
    }

    constructor() {
      super();
      this.attachShadow({ mode: "open" });

      let style = document.createElement("link");
      style.rel = "stylesheet";
      style.href = "chrome://global/content/elements/panel-item.css";

      this.button = document.createElement("button");
      this.#setButtonAttributes();

      this.button.setAttribute("part", "button");
      // Use a XUL label element if possible to show the accesskey.
      this.label = document.createXULElement
        ? document.createXULElement("label")
        : document.createElement("span");

      this.button.appendChild(this.label);

      let supportLinkSlot = document.createElement("slot");
      supportLinkSlot.name = "support-link";

      this.#defaultSlot = document.createElement("slot");
      this.#defaultSlot.style.display = "none";

      this.shadowRoot.append(
        style,
        this.button,
        supportLinkSlot,
        this.#defaultSlot
      );
    }

    connectedCallback() {
      if (!this._l10nRootConnected && document.l10n) {
        document.l10n.connectRoot(this.shadowRoot);
        this._l10nRootConnected = true;
      }

      this.panel =
        this.getRootNode()?.host?.closest("panel-list") ||
        this.closest("panel-list");

      if (!this.#initialized) {
        this.#initialized = true;
        // When click listeners are added to the panel-item it creates a node in
        // the a11y tree for this element. This breaks the association between the
        // menu and the button[role="menuitem"] in this shadow DOM and causes
        // announcement issues with screen readers. (bug 995064)
        this.setAttribute("role", "presentation");

        this.#setLabelContents();

        // When our content changes, move the text into the label. It doesn't work
        // with a <slot>, unfortunately.
        new MutationObserver(() => this.#setLabelContents()).observe(this, {
          characterData: true,
          childList: true,
          subtree: true,
        });

        if (this.hasSubmenu) {
          this.panel.setAttribute("has-submenu", "");
          this.icon = document.createElement("div");
          this.icon.setAttribute("class", "submenu-icon");
          this.label.setAttribute("class", "submenu-label");

          this.button.setAttribute("class", "submenu-container");
          this.button.appendChild(this.icon);

          this.submenuSlot = document.createElement("slot");
          this.submenuSlot.name = "submenu";

          this.shadowRoot.append(this.submenuSlot);

          this.setSubmenuContents();
        }
      }

      if (this.panel) {
        this.panel.addEventListener("hidden", this);
        this.panel.addEventListener("shown", this);
      }

      if (this.hasSubmenu) {
        this.addEventListener("mouseenter", this);
        this.addEventListener("mouseleave", this);
        this.addEventListener("keydown", this);
      }
    }

    disconnectedCallback() {
      if (this._l10nRootConnected) {
        document.l10n.disconnectRoot(this.shadowRoot);
        this._l10nRootConnected = false;
      }

      if (this.panel) {
        this.panel.removeEventListener("hidden", this);
        this.panel.removeEventListener("shown", this);
        this.panel = null;
      }

      if (this.hasSubmenu) {
        this.removeEventListener("mouseenter", this);
        this.removeEventListener("mouseleave", this);
        this.removeEventListener("keydown", this);
      }
    }

    get hasSubmenu() {
      return this.hasAttribute("submenu");
    }

    attributeChangedCallback(name, oldVal, newVal) {
      if (name === "accesskey") {
        // Bug 1037709 - Accesskey doesn't work in shadow DOM.
        // Ideally we'd have the accesskey set in shadow DOM, and on
        // attributeChangedCallback we'd just update the shadow DOM accesskey.

        // Skip this change event if we caused it.
        if (this._modifyingAccessKey) {
          this._modifyingAccessKey = false;
          return;
        }

        this.label.accessKey = newVal || "";

        // Bug 1588156 - Accesskey is not ignored for hidden non-input elements.
        // Since the accesskey won't be ignored, we need to remove it ourselves
        // when the panel is closed, and move it back when it opens.
        if (!this.panel || !this.panel.open) {
          // When the panel isn't open, just store the key for later.
          this._accessKey = newVal || null;
          this._modifyingAccessKey = true;
          this.accessKey = "";
        } else {
          this._accessKey = null;
        }
      } else if (name === "type") {
        this.#setButtonAttributes();
      }
    }

    #setButtonAttributes() {
      if (this.type == "checkbox") {
        this.button.setAttribute("role", "menuitemcheckbox");
        this.button.setAttribute("aria-checked", this.checked);
      } else {
        this.button.setAttribute("role", "menuitem");
        this.button.removeAttribute("aria-checked");
      }
    }

    #setLabelContents() {
      this.label.textContent = this.#defaultSlot
        .assignedNodes()
        .map(node => node.textContent)
        .join("");
    }

    setSubmenuContents() {
      this.submenuPanel = this.submenuSlot.assignedNodes()[0];
      if (this.submenuPanel) {
        this.shadowRoot.append(this.submenuPanel);
      }
    }

    get disabled() {
      return this.button.hasAttribute("disabled");
    }

    set disabled(val) {
      this.button.toggleAttribute("disabled", val);
    }

    get checked() {
      if (this.type !== "checkbox") {
        return false;
      }
      return this.hasAttribute("checked");
    }

    set checked(val) {
      if (this.type == "checkbox") {
        this.toggleAttribute("checked", val);
        this.button.setAttribute("aria-checked", !!val);
      }
    }

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

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

    focus() {
      this.button.focus();
    }

    setArrowKeyRTL() {
      let arrowOpenKey = "ArrowRight";
      let arrowCloseKey = "ArrowLeft";

      if (this.submenuPanel.isDocumentRTL()) {
        arrowOpenKey = "ArrowLeft";
        arrowCloseKey = "ArrowRight";
      }
      return [arrowOpenKey, arrowCloseKey];
    }

    handleEvent(e) {
      // Bug 1588156 - Accesskey is not ignored for hidden non-input elements.
      // Since the accesskey won't be ignored, we need to remove it ourselves
      // when the panel is closed, and move it back when it opens.
      switch (e.type) {
        case "shown":
          if (this._accessKey) {
            this.accessKey = this._accessKey;
            this._accessKey = null;
          }
          break;
        case "hidden":
          if (this.accessKey) {
            this._accessKey = this.accessKey;
            this._modifyingAccessKey = true;
            this.accessKey = "";
          }
          break;
        case "mouseenter":
        case "mouseleave":
          this.submenuPanel.toggle(e);
          break;
        case "keydown":
          let [arrowOpenKey, arrowCloseKey] = this.setArrowKeyRTL();
          if (e.key === arrowOpenKey) {
            this.submenuPanel.show(e, e.target);
            e.stopPropagation();
          }
          if (e.key === arrowCloseKey) {
            this.submenuPanel.hide(e, { force: true }, e.target);
            e.stopPropagation();
          }
          break;
      }
    }
  }
  customElements.define("panel-item", PanelItem);
}
