/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

/**
 * Test the keyboard behavior of PanelViews.
 */

const kEmbeddedDocUrl =
  'data:text/html,<textarea id="docTextarea">value</textarea><button id="docButton"></button>';

let gAnchor;
let gPanel;
let gPanelMultiView;
let gMainView;
let gMainContext;
let gMainButton1;
let gMainMenulist;
let gMainRadiogroup;
let gMainTextbox;
let gMainButton2;
let gMainButton3;
let gCheckbox;
let gNamespacedLink;
let gLink;
let gMainTabOrder;
let gMainArrowOrder;
let gSubView;
let gSubButton;
let gSubTextarea;
let gBrowserView;
let gBrowserBrowser;
let gIframeView;
let gIframeIframe;
let gToggle;

async function openPopup() {
  let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown");
  PanelMultiView.openPopup(gPanel, gAnchor, "bottomright topright");
  await shown;
}

async function hidePopup() {
  let hidden = BrowserTestUtils.waitForEvent(gPanel, "popuphidden");
  PanelMultiView.hidePopup(gPanel);
  await hidden;
}

async function showSubView(view = gSubView) {
  let shown = BrowserTestUtils.waitForEvent(view, "ViewShown");
  // We must show with an anchor so the Back button is generated.
  gPanelMultiView.showSubView(view, gMainButton1);
  await shown;
}

async function expectFocusAfterKey(aKey, aFocus) {
  let res = aKey.match(/^(Shift\+)?(.+)$/);
  let shift = Boolean(res[1]);
  let key;
  if (res[2].length == 1) {
    key = res[2]; // Character.
  } else {
    key = "KEY_" + res[2]; // Tab, ArrowRight, etc.
  }
  info("Waiting for focus on " + aFocus.id);
  let focused = BrowserTestUtils.waitForEvent(aFocus, "focus");
  EventUtils.synthesizeKey(key, { shiftKey: shift });
  await focused;
  ok(true, aFocus.id + " focused after " + aKey + " pressed");
}

add_setup(async function () {
  // This shouldn't be necessary - but it is, because we use same-process frames.
  // https://bugzilla.mozilla.org/show_bug.cgi?id=1565276 covers improving this.
  await SpecialPowers.pushPrefEnv({
    set: [["security.allow_unsafe_parent_loads", true]],
  });
  let navBar = document.getElementById("nav-bar");
  gAnchor = document.createXULElement("toolbarbutton");
  navBar.appendChild(gAnchor);
  gPanel = document.createXULElement("panel");
  navBar.appendChild(gPanel);
  gPanelMultiView = document.createXULElement("panelmultiview");
  gPanelMultiView.setAttribute("mainViewId", "testMainView");
  gPanel.appendChild(gPanelMultiView);

  gMainView = document.createXULElement("panelview");
  gMainView.id = "testMainView";
  gPanelMultiView.appendChild(gMainView);
  gMainContext = document.createXULElement("menupopup");
  gMainContext.id = "gMainContext";
  gMainView.appendChild(gMainContext);
  gMainContext.appendChild(document.createXULElement("menuitem"));
  gMainButton1 = document.createXULElement("button");
  gMainButton1.id = "gMainButton1";
  gMainView.appendChild(gMainButton1);
  // We use this for anchoring subviews, so it must have a label.
  gMainButton1.setAttribute("label", "gMainButton1");
  gMainButton1.setAttribute("context", "gMainContext");
  gMainMenulist = document.createXULElement("menulist");
  gMainMenulist.id = "gMainMenulist";
  gMainView.appendChild(gMainMenulist);
  let menuPopup = document.createXULElement("menupopup");
  gMainMenulist.appendChild(menuPopup);
  let item = document.createXULElement("menuitem");
  item.setAttribute("value", "1");
  item.setAttribute("selected", "true");
  menuPopup.appendChild(item);
  item = document.createXULElement("menuitem");
  item.setAttribute("value", "2");
  menuPopup.appendChild(item);
  gMainRadiogroup = document.createXULElement("radiogroup");
  gMainRadiogroup.id = "gMainRadiogroup";
  gMainView.appendChild(gMainRadiogroup);
  let radio = document.createXULElement("radio");
  radio.setAttribute("value", "1");
  radio.setAttribute("selected", "true");
  gMainRadiogroup.appendChild(radio);
  radio = document.createXULElement("radio");
  radio.setAttribute("value", "2");
  gMainRadiogroup.appendChild(radio);
  gMainTextbox = document.createElement("input");
  gMainTextbox.id = "gMainTextbox";
  gMainView.appendChild(gMainTextbox);
  gMainTextbox.setAttribute("value", "value");
  gMainButton2 = document.createXULElement("button");
  gMainButton2.id = "gMainButton2";
  gMainView.appendChild(gMainButton2);
  gMainButton3 = document.createXULElement("button");
  gMainButton3.id = "gMainButton3";
  gMainView.appendChild(gMainButton3);
  gCheckbox = document.createXULElement("checkbox");
  gCheckbox.id = "gCheckbox";
  gMainView.appendChild(gCheckbox);

  // moz-support-links in XUL documents are created with the
  // <html:a> tag and so we need to test this separately from
  // <a> tags.
  gNamespacedLink = document.createElementNS(
    "http://www.w3.org/1999/xhtml",
    "html:a"
  );
  gNamespacedLink.href = "www.mozilla.org";
  gNamespacedLink.innerText = "gNamespacedLink";
  gNamespacedLink.id = "gNamespacedLink";
  gMainView.appendChild(gNamespacedLink);
  gLink = document.createElement("a");
  gLink.href = "www.mozilla.org";
  gLink.innerText = "gLink";
  gLink.id = "gLink";
  gMainView.appendChild(gLink);
  gToggle = document.createElement("moz-toggle");
  gToggle.label = "Test label";
  gMainView.appendChild(gToggle);

  gMainTabOrder = [
    gMainButton1,
    gMainMenulist,
    gMainRadiogroup,
    gMainTextbox,
    gMainButton2,
    gMainButton3,
    gCheckbox,
    gNamespacedLink,
    gLink,
    gToggle,
  ];
  gMainArrowOrder = [
    gMainButton1,
    gMainButton2,
    gMainButton3,
    gCheckbox,
    gNamespacedLink,
    gLink,
    gToggle,
  ];

  gSubView = document.createXULElement("panelview");
  gSubView.id = "testSubView";
  gPanelMultiView.appendChild(gSubView);
  gSubButton = document.createXULElement("button");
  gSubView.appendChild(gSubButton);
  gSubTextarea = document.createElementNS(
    "http://www.w3.org/1999/xhtml",
    "textarea"
  );
  gSubTextarea.id = "gSubTextarea";
  gSubView.appendChild(gSubTextarea);
  gSubTextarea.value = "value";

  gBrowserView = document.createXULElement("panelview");
  gBrowserView.id = "testBrowserView";
  gPanelMultiView.appendChild(gBrowserView);
  gBrowserBrowser = document.createXULElement("browser");
  gBrowserBrowser.id = "GBrowserBrowser";
  gBrowserBrowser.setAttribute("type", "content");
  gBrowserBrowser.setAttribute("src", kEmbeddedDocUrl);
  gBrowserBrowser.style.minWidth = gBrowserBrowser.style.minHeight = "100px";
  gBrowserView.appendChild(gBrowserBrowser);

  gIframeView = document.createXULElement("panelview");
  gIframeView.id = "testIframeView";
  gPanelMultiView.appendChild(gIframeView);
  gIframeIframe = document.createXULElement("iframe");
  gIframeIframe.id = "gIframeIframe";
  gIframeIframe.setAttribute("src", kEmbeddedDocUrl);
  gIframeView.appendChild(gIframeIframe);

  registerCleanupFunction(() => {
    gAnchor.remove();
    gPanel.remove();
  });
});

// Test that the tab key focuses all expected controls.
add_task(async function testTab() {
  await openPopup();
  for (let elem of gMainTabOrder) {
    await expectFocusAfterKey("Tab", elem);
  }
  // Wrap around.
  await expectFocusAfterKey("Tab", gMainTabOrder[0]);
  await hidePopup();
});

// Test that the shift+tab key focuses all expected controls.
add_task(async function testShiftTab() {
  await openPopup();
  for (let i = gMainTabOrder.length - 1; i >= 0; --i) {
    await expectFocusAfterKey("Shift+Tab", gMainTabOrder[i]);
  }
  // Wrap around.
  await expectFocusAfterKey(
    "Shift+Tab",
    gMainTabOrder[gMainTabOrder.length - 1]
  );
  await hidePopup();
});

// Test that the down arrow key skips menulists and textboxes.
add_task(async function testDownArrow() {
  await openPopup();
  for (let elem of gMainArrowOrder) {
    await expectFocusAfterKey("ArrowDown", elem);
  }
  // Wrap around.
  await expectFocusAfterKey("ArrowDown", gMainArrowOrder[0]);
  await hidePopup();
});

// Test that the up arrow key skips menulists and textboxes.
add_task(async function testUpArrow() {
  await openPopup();
  for (let i = gMainArrowOrder.length - 1; i >= 0; --i) {
    await expectFocusAfterKey("ArrowUp", gMainArrowOrder[i]);
  }
  // Wrap around.
  await expectFocusAfterKey(
    "ArrowUp",
    gMainArrowOrder[gMainArrowOrder.length - 1]
  );
  await hidePopup();
});

// Test that the home/end keys move to the first/last controls.
add_task(async function testHomeEnd() {
  await openPopup();
  await expectFocusAfterKey("Home", gMainArrowOrder[0]);
  await expectFocusAfterKey("End", gMainArrowOrder[gMainArrowOrder.length - 1]);
  await hidePopup();
});

// Test that the up/down arrow keys work as expected in menulists.
add_task(async function testArrowsMenulist() {
  await openPopup();
  gMainMenulist.focus();
  is(document.activeElement, gMainMenulist, "menulist focused");
  is(gMainMenulist.value, "1", "menulist initial value 1");
  if (AppConstants.platform == "macosx") {
    // On Mac, down/up arrows just open the menulist.
    let popup = gMainMenulist.menupopup;
    for (let key of ["ArrowDown", "ArrowUp"]) {
      let shown = BrowserTestUtils.waitForEvent(popup, "popupshown");
      EventUtils.synthesizeKey("KEY_" + key);
      await shown;
      ok(gMainMenulist.open, "menulist open after " + key);
      let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
      EventUtils.synthesizeKey("KEY_Escape");
      await hidden;
      ok(!gMainMenulist.open, "menulist closed after Escape");
    }
  } else {
    // On other platforms, down/up arrows change the value without opening the
    // menulist.
    EventUtils.synthesizeKey("KEY_ArrowDown");
    is(
      document.activeElement,
      gMainMenulist,
      "menulist still focused after ArrowDown"
    );
    is(gMainMenulist.value, "2", "menulist value 2 after ArrowDown");
    EventUtils.synthesizeKey("KEY_ArrowUp");
    is(
      document.activeElement,
      gMainMenulist,
      "menulist still focused after ArrowUp"
    );
    is(gMainMenulist.value, "1", "menulist value 1 after ArrowUp");
  }
  await hidePopup();
});

// Test that the tab key closes an open menu list.
add_task(async function testTabOpenMenulist() {
  await openPopup();
  gMainMenulist.focus();
  is(document.activeElement, gMainMenulist, "menulist focused");
  let popup = gMainMenulist.menupopup;
  let shown = BrowserTestUtils.waitForEvent(popup, "popupshown");
  gMainMenulist.open = true;
  await shown;
  ok(gMainMenulist.open, "menulist open");
  let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
  EventUtils.synthesizeKey("KEY_Tab");
  await menuHidden;
  ok(!gMainMenulist.open, "menulist closed after Tab");
  is(gPanel.state, "open", "Panel should be open");
  await hidePopup();
});

if (AppConstants.platform == "macosx") {
  // Test that using the mouse to open a menulist still allows keyboard navigation
  // inside it.
  add_task(async function testNavigateMouseOpenedMenulist() {
    await openPopup();
    let popup = gMainMenulist.menupopup;
    let shown = BrowserTestUtils.waitForEvent(popup, "popupshown");
    gMainMenulist.open = true;
    await shown;
    ok(gMainMenulist.open, "menulist open");
    let oldFocus = document.activeElement;
    let oldSelectedItem = gMainMenulist.selectedItem;
    ok(
      oldSelectedItem.hasAttribute("_moz-menuactive"),
      "Selected item should show up as active"
    );
    EventUtils.synthesizeKey("KEY_ArrowDown");
    await TestUtils.waitForCondition(
      () => !oldSelectedItem.hasAttribute("_moz-menuactive")
    );
    is(oldFocus, document.activeElement, "Focus should not move on mac");
    ok(
      !oldSelectedItem.hasAttribute("_moz-menuactive"),
      "Selected item should change"
    );

    let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
    EventUtils.synthesizeKey("KEY_Tab");
    await menuHidden;
    ok(!gMainMenulist.open, "menulist closed after Tab");
    is(gPanel.state, "open", "Panel should be open");
    await hidePopup();
  });
}

// Test that the up/down arrow keys work as expected in radiogroups.
add_task(async function testArrowsRadiogroup() {
  await openPopup();
  gMainRadiogroup.focus();
  is(document.activeElement, gMainRadiogroup, "radiogroup focused");
  is(gMainRadiogroup.value, "1", "radiogroup initial value 1");
  EventUtils.synthesizeKey("KEY_ArrowDown");
  is(
    document.activeElement,
    gMainRadiogroup,
    "radiogroup still focused after ArrowDown"
  );
  is(gMainRadiogroup.value, "2", "radiogroup value 2 after ArrowDown");
  EventUtils.synthesizeKey("KEY_ArrowUp");
  is(
    document.activeElement,
    gMainRadiogroup,
    "radiogroup still focused after ArrowUp"
  );
  is(gMainRadiogroup.value, "1", "radiogroup value 1 after ArrowUp");
  await hidePopup();
});

// Test that pressing space in a textbox inserts a space (instead of trying to
// activate the control).
add_task(async function testSpaceTextbox() {
  await openPopup();
  gMainTextbox.focus();
  gMainTextbox.selectionStart = gMainTextbox.selectionEnd = 0;
  EventUtils.synthesizeKey(" ");
  is(gMainTextbox.value, " value", "Space typed into textbox");
  gMainTextbox.value = "value";
  await hidePopup();
});

// Tests that the left arrow key normally moves back to the previous view.
add_task(async function testLeftArrow() {
  await openPopup();
  await showSubView();
  let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown");
  EventUtils.synthesizeKey("KEY_ArrowLeft");
  await shown;
  ok("Moved to previous view after ArrowLeft");
  await hidePopup();
});

// Tests that the left arrow key moves the caret in a textarea in a subview
// (instead of going back to the previous view).
add_task(async function testLeftArrowTextarea() {
  await openPopup();
  await showSubView();
  gSubTextarea.focus();
  is(document.activeElement, gSubTextarea, "textarea focused");
  EventUtils.synthesizeKey("KEY_End");
  is(gSubTextarea.selectionStart, 5, "selectionStart 5 after End");
  EventUtils.synthesizeKey("KEY_ArrowLeft");
  is(gSubTextarea.selectionStart, 4, "selectionStart 4 after ArrowLeft");
  is(document.activeElement, gSubTextarea, "textarea still focused");
  await hidePopup();
});

// Test navigation to a button which is initially disabled and later enabled.
add_task(async function testDynamicButton() {
  gMainButton2.disabled = true;
  await openPopup();
  await expectFocusAfterKey("ArrowDown", gMainButton1);
  await expectFocusAfterKey("ArrowDown", gMainButton3);
  gMainButton2.disabled = false;
  await expectFocusAfterKey("ArrowUp", gMainButton2);
  await hidePopup();
});

add_task(async function testActivation() {
  function checkActivated(elem, activationFn, reason) {
    let activated = false;
    elem.onclick = function () {
      activated = true;
    };
    activationFn();
    ok(activated, "Should have activated button after " + reason);
    elem.onclick = null;
  }
  await openPopup();
  await expectFocusAfterKey("ArrowDown", gMainButton1);
  checkActivated(
    gMainButton1,
    () => EventUtils.synthesizeKey("KEY_Enter"),
    "pressing enter"
  );
  checkActivated(
    gMainButton1,
    () => EventUtils.synthesizeKey(" "),
    "pressing space"
  );
  checkActivated(
    gMainButton1,
    () => EventUtils.synthesizeKey("KEY_Enter", { code: "NumpadEnter" }),
    "pressing numpad enter"
  );
  await hidePopup();
});

// Test that keyboard activation works for buttons responding to mousedown
// events (instead of command or click). The Library button does this, for
// example.
add_task(async function testActivationMousedown() {
  await openPopup();
  await expectFocusAfterKey("ArrowDown", gMainButton1);
  let activated = false;
  gMainButton1.onmousedown = function () {
    activated = true;
  };
  EventUtils.synthesizeKey(" ");
  ok(activated, "mousedown activated after space");
  gMainButton1.onmousedown = null;
  await hidePopup();
});

// Test that tab and the arrow keys aren't overridden in embedded documents.
async function testTabArrowsEmbeddedDoc(aView, aEmbedder) {
  await openPopup();
  await showSubView(aView);
  let doc = aEmbedder.contentDocument;
  if (doc.readyState != "complete" || doc.location.href != kEmbeddedDocUrl) {
    info(`Embedded doc readyState ${doc.readyState}, location ${doc.location}`);
    info("Waiting for load on embedder");
    // Browsers don't fire load events, and iframes don't fire load events in
    // typeChrome windows. We can handle both by using a capturing event
    // listener to capture the load event from the child document.
    await BrowserTestUtils.waitForEvent(aEmbedder, "load", true);
    // The original doc might have been a temporary about:blank, so fetch it
    // again.
    doc = aEmbedder.contentDocument;
  }
  is(doc.location.href, kEmbeddedDocUrl, "Embedded doc has correct URl");
  let backButton = aView.querySelector(".subviewbutton-back");
  backButton.id = "docBack";
  await expectFocusAfterKey("Tab", backButton);
  // Documents don't have an id property, but expectFocusAfterKey wants one.
  doc.id = "doc";
  await expectFocusAfterKey("Tab", doc);
  // Make sure tab/arrows aren't overridden within the embedded document.
  let textarea = doc.getElementById("docTextarea");
  // Tab should really focus the textarea, but default tab handling seems to
  // skip everything inside the embedder element when run in this test. This
  // behaves as expected in real panels, though. Force focus to the textarea
  // and then test from there.
  textarea.focus();
  is(doc.activeElement, textarea, "textarea focused");
  is(textarea.selectionStart, 0, "selectionStart initially 0");
  EventUtils.synthesizeKey("KEY_ArrowRight");
  is(textarea.selectionStart, 1, "selectionStart 1 after ArrowRight");
  EventUtils.synthesizeKey("KEY_ArrowLeft");
  is(textarea.selectionStart, 0, "selectionStart 0 after ArrowLeft");
  is(doc.activeElement, textarea, "textarea still focused");
  let docButton = doc.getElementById("docButton");
  await expectFocusAfterKey("Tab", docButton);
  await hidePopup();
}

// Test that tab and the arrow keys aren't overridden in embedded browsers.
add_task(async function testTabArrowsBrowser() {
  await testTabArrowsEmbeddedDoc(gBrowserView, gBrowserBrowser);
});

// Test that tab and the arrow keys aren't overridden in embedded iframes.
add_task(async function testTabArrowsIframe() {
  await testTabArrowsEmbeddedDoc(gIframeView, gIframeIframe);
});

// Test that the arrow keys aren't overridden in context menus.
add_task(async function testArowsContext() {
  await openPopup();
  await expectFocusAfterKey("ArrowDown", gMainButton1);
  let shown = BrowserTestUtils.waitForEvent(gMainContext, "popupshown");
  // There's no cross-platform way to open a context menu from the keyboard.
  gMainContext.openPopup(gMainButton1);
  await shown;
  let item = gMainContext.children[0];
  ok(
    !item.getAttribute("_moz-menuactive"),
    "First context menu item initially inactive"
  );
  let active = BrowserTestUtils.waitForEvent(item, "DOMMenuItemActive");
  EventUtils.synthesizeKey("KEY_ArrowDown");
  await active;
  ok(
    item.getAttribute("_moz-menuactive"),
    "First context menu item active after ArrowDown"
  );
  is(
    document.activeElement,
    gMainButton1,
    "gMainButton1 still focused after ArrowDown"
  );
  let hidden = BrowserTestUtils.waitForEvent(gMainContext, "popuphidden");
  gMainContext.hidePopup();
  await hidden;
  await hidePopup();
});

add_task(async function testMozToggle() {
  await openPopup();
  is(gToggle.pressed, false, "The toggle is not pressed initially.");
  // Focus the toggle via keyboard navigation.
  while (document.activeElement !== gToggle) {
    EventUtils.synthesizeKey("KEY_Tab");
  }
  EventUtils.synthesizeKey(" ");
  await gToggle.updateComplete;
  is(gToggle.pressed, true, "Toggle pressed state changes via spacebar.");
  EventUtils.synthesizeKey("KEY_Enter");
  await gToggle.updateComplete;
  is(gToggle.pressed, false, "Toggle pressed state changes via enter.");
  await hidePopup();
});
