diff --git a/browser/base/content/test/static/browser_parsable_css.js b/browser/base/content/test/static/browser_parsable_css.js index 0f122909d1b6..0a28bee2f1ff 100644 --- a/browser/base/content/test/static/browser_parsable_css.js +++ b/browser/base/content/test/static/browser_parsable_css.js @@ -65,9 +65,6 @@ let whitelist = [ intermittent: true, errorMessage: /Property contained reference to invalid variable.*background/i, isFromDevTools: true}, - {sourceName: /pictureinpicture\/toggle.css$/i, - errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i, - isFromDevTools: false}, ]; if (!Services.prefs.getBoolPref("layout.css.xul-box-display-values.content.enabled")) { diff --git a/browser/components/controlcenter/content/panel.inc.xul b/browser/components/controlcenter/content/panel.inc.xul index e0d3cb0c857c..5c6c0ee1b5ad 100644 --- a/browser/components/controlcenter/content/panel.inc.xul +++ b/browser/components/controlcenter/content/panel.inc.xul @@ -13,8 +13,7 @@ orient="vertical"> + mainViewId="identity-popup-mainView"> diff --git a/browser/components/customizableui/PanelMultiView.jsm b/browser/components/customizableui/PanelMultiView.jsm index d6df22c7d989..3e73dc15585f 100644 --- a/browser/components/customizableui/PanelMultiView.jsm +++ b/browser/components/customizableui/PanelMultiView.jsm @@ -644,6 +644,10 @@ var PanelMultiView = class extends AssociatedToNode { if (!prevPanelView.active) { return; } + // If prevPanelView._doingKeyboardActivation is true, it will be reset to + // false synchronously. Therefore, we must capture it before we use any + // "await" statements. + let doingKeyboardActivation = prevPanelView._doingKeyboardActivation; // Marking the view that is about to scrolled out of the visible area as // inactive will prevent re-entrancy and also disable keyboard navigation. // From this point onwards, "await" statements can be used safely. @@ -692,6 +696,7 @@ var PanelMultiView = class extends AssociatedToNode { } } + nextPanelView.focusWhenActive = doingKeyboardActivation; this._activateView(nextPanelView); } @@ -814,7 +819,7 @@ var PanelMultiView = class extends AssociatedToNode { if (panelView.isOpenIn(this)) { panelView.active = true; if (panelView.focusWhenActive) { - panelView.focusFirstNavigableElement(); + panelView.focusFirstNavigableElement(false, true); panelView.focusWhenActive = false; } panelView.dispatchCustomEvent("ViewShown"); @@ -1400,38 +1405,70 @@ var PanelView = class extends AssociatedToNode { } /** - * Array of enabled elements that can be selected with the keyboard. This - * means all buttons, menulists, and text links including the back button. - * - * This list is cached until the view is closed, so elements that become - * enabled later may not be navigable. + * Determine whether an element can only be navigated to with tab/shift+tab, + * not the arrow keys. */ - get _navigableElements() { - if (this.__navigableElements) { - return this.__navigableElements; - } + _isNavigableWithTabOnly(element) { + let tag = element.localName; + return tag == "menulist" || tag == "textbox" || tag == "input" + || tag == "textarea"; + } - let navigableElements = Array.from(this.node.querySelectorAll( - ":-moz-any(button,toolbarbutton,menulist,.text-link,.navigable):not([disabled])")); - return this.__navigableElements = navigableElements.filter(element => { - // Set the "tabindex" attribute to make sure the element is focusable. - if (!element.hasAttribute("tabindex")) { - element.setAttribute("tabindex", "0"); + /** + * Make a TreeWalker for keyboard navigation. + * + * @param {Boolean} arrowKey If `true`, elements only navigable with tab are + * excluded. + */ + _makeNavigableTreeWalker(arrowKey) { + let filter = node => { + if (node.disabled) { + return NodeFilter.FILTER_REJECT; } - if (element.hasAttribute("disabled")) { - return false; + let bounds = this._getBoundsWithoutFlushing(node); + if (bounds.width == 0 || bounds.height == 0) { + return NodeFilter.FILTER_REJECT; } - let bounds = this._getBoundsWithoutFlushing(element); - return bounds.width > 0 && bounds.height > 0; - }); + if (node.tagName == "button" || node.tagName == "toolbarbutton" || + node.classList.contains("text-link") || + node.classList.contains("navigable") || + (!arrowKey && this._isNavigableWithTabOnly(node))) { + // Set the tabindex attribute to make sure the node is focusable. + if (!node.hasAttribute("tabindex")) { + node.setAttribute("tabindex", "-1"); + } + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + return this.document.createTreeWalker(this.node, NodeFilter.SHOW_ELEMENT, + filter); + } + + /** + * Get a TreeWalker which finds elements navigable with tab/shift+tab. + */ + get _tabNavigableWalker() { + if (!this.__tabNavigableWalker) { + this.__tabNavigableWalker = this._makeNavigableTreeWalker(false); + } + return this.__tabNavigableWalker; + } + + /** + * Get a TreeWalker which finds elements navigable with up/down arrow keys. + */ + get _arrowNavigableWalker() { + if (!this.__arrowNavigableWalker) { + this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true); + } + return this.__arrowNavigableWalker; } /** * Element that is currently selected with the keyboard, or null if no element * is selected. Since the reference is held weakly, it can become null or * undefined at any time. - * - * The element is usually, but not necessarily, among the _navigableElements. */ get selectedElement() { return this._selectedElement && this._selectedElement.get(); @@ -1447,18 +1484,36 @@ var PanelView = class extends AssociatedToNode { /** * Focuses and moves keyboard selection to the first navigable element. * This is a no-op if there are no navigable elements. + * + * @param {Boolean} homeKey `true` if this is for the home key. + * @param {Boolean} skipBack `true` if the Back button should be skipped. */ - focusFirstNavigableElement() { - this.selectedElement = this._navigableElements[0]; + focusFirstNavigableElement(homeKey = false, skipBack = false) { + // The home key is conceptually similar to the up/down arrow keys. + let walker = homeKey ? + this._arrowNavigableWalker : this._tabNavigableWalker; + walker.currentNode = walker.root; + this.selectedElement = walker.firstChild(); + if (skipBack && walker.currentNode + && walker.currentNode.classList.contains("subviewbutton-back") + && walker.nextNode()) { + this.selectedElement = walker.currentNode; + } this.focusSelectedElement(); } /** * Focuses and moves keyboard selection to the last navigable element. * This is a no-op if there are no navigable elements. + * + * @param {Boolean} endKey `true` if this is for the end key. */ - focusLastNavigableElement() { - this.selectedElement = this._navigableElements[this._navigableElements.length - 1]; + focusLastNavigableElement(endKey = false) { + // The end key is conceptually similar to the up/down arrow keys. + let walker = endKey ? + this._arrowNavigableWalker : this._tabNavigableWalker; + walker.currentNode = walker.root; + this.selectedElement = walker.lastChild(); this.focusSelectedElement(); } @@ -1466,54 +1521,26 @@ var PanelView = class extends AssociatedToNode { * Based on going up or down, select the previous or next focusable element. * * @param {Boolean} isDown whether we're going down (true) or up (false). + * @param {Boolean} arrowKey `true` if this is for the up/down arrow keys. * * @return {DOMNode} the element we selected. */ - moveSelection(isDown) { - let buttons = this._navigableElements; - let lastSelected = this.selectedElement; - let newButton = null; - let maxIdx = buttons.length - 1; - if (lastSelected) { - let buttonIndex = buttons.indexOf(lastSelected); - if (buttonIndex != -1) { - // Buttons may get selected whilst the panel is shown, so add an extra - // check here. - do { - buttonIndex = buttonIndex + (isDown ? 1 : -1); - } while (buttons[buttonIndex] && buttons[buttonIndex].disabled); - if (isDown && buttonIndex > maxIdx) - buttonIndex = 0; - else if (!isDown && buttonIndex < 0) - buttonIndex = maxIdx; - newButton = buttons[buttonIndex]; - } else { - // The previously selected item is no longer selectable. Find the next item: - let allButtons = lastSelected.closest("panelview").getElementsByTagName("toolbarbutton"); - let maxAllButtonIdx = allButtons.length - 1; - let allButtonIndex = allButtons.indexOf(lastSelected); - while (allButtonIndex >= 0 && allButtonIndex <= maxAllButtonIdx) { - allButtonIndex++; - // Check if the next button is in the list of focusable buttons. - buttonIndex = buttons.indexOf(allButtons[allButtonIndex]); - if (buttonIndex != -1) { - // If it is, just use that button if we were going down, or the previous one - // otherwise. If this was the first button, newButton will end up undefined, - // which is fine because we'll fall back to using the last button at the - // bottom of this method. - newButton = buttons[isDown ? buttonIndex : buttonIndex - 1]; - break; - } - } - } + moveSelection(isDown, arrowKey = false) { + let walker = arrowKey ? + this._arrowNavigableWalker : this._tabNavigableWalker; + let oldSel = this.selectedElement; + let newSel; + if (oldSel) { + walker.currentNode = oldSel; + newSel = isDown ? walker.nextNode() : walker.previousNode(); } - // If we couldn't find something, select the first or last item: - if (!newButton) { - newButton = buttons[isDown ? 0 : maxIdx]; + if (!newSel) { + walker.currentNode = walker.root; + newSel = isDown ? walker.firstChild() : walker.lastChild(); } - this.selectedElement = newButton; - return newButton; + this.selectedElement = newSel; + return newSel; } /** @@ -1538,38 +1565,70 @@ var PanelView = class extends AssociatedToNode { return; } - let buttons = this._navigableElements; - if (!buttons.length) { - return; - } - let stop = () => { event.stopPropagation(); event.preventDefault(); }; + // If the focused element is only navigable with tab, it wants the arrow + // keys, etc. We shouldn't handle any keys except tab and shift+tab. + // We make a function for this for performance reasons: we only want to + // check this for keys we potentially care about, not *all* keys. + let tabOnly = () => { + // We use the real focus rather than this.selectedElement because focus + // might have been moved without keyboard navigation (e.g. mouse click) + // and this.selectedElement is only updated for keyboard navigation. + let focus = this.document.activeElement; + if (!focus) { + return false; + } + // Make sure the focus is actually inside the panel. + // (It might not be if the panel was opened with the mouse.) + // We use Node.compareDocumentPosition because Node.contains doesn't + // behave as expected for anonymous content; e.g. the input inside a + // textbox. + if (!(this.node.compareDocumentPosition(focus) + & Node.DOCUMENT_POSITION_CONTAINED_BY)) { + return false; + } + return this._isNavigableWithTabOnly(focus); + }; + let keyCode = event.code; switch (keyCode) { case "ArrowDown": case "ArrowUp": + if (tabOnly()) { + break; + } + // Fall-through... case "Tab": { stop(); let isDown = (keyCode == "ArrowDown") || (keyCode == "Tab" && !event.shiftKey); - let button = this.moveSelection(isDown); + let button = this.moveSelection(isDown, keyCode != "Tab"); button.focus(); break; } case "Home": + if (tabOnly()) { + break; + } stop(); - this.focusFirstNavigableElement(); + this.focusFirstNavigableElement(true); break; case "End": + if (tabOnly()) { + break; + } stop(); - this.focusLastNavigableElement(); + this.focusLastNavigableElement(true); break; case "ArrowLeft": case "ArrowRight": { + if (tabOnly()) { + break; + } stop(); if ((!this.window.RTL_UI && keyCode == "ArrowLeft") || (this.window.RTL_UI && keyCode == "ArrowRight")) { @@ -1586,11 +1645,15 @@ var PanelView = class extends AssociatedToNode { } case "Space": case "Enter": { + if (tabOnly()) { + break; + } let button = this.selectedElement; if (!button) break; stop(); + this._doingKeyboardActivation = true; // Unfortunately, 'tabindex' doesn't execute the default action, so // we explicitly do this here. // We are sending a command event and then a click event. @@ -1599,6 +1662,7 @@ var PanelView = class extends AssociatedToNode { button.doCommand(); let clickEvent = new event.target.ownerGlobal.MouseEvent("click", {"bubbles": true}); button.dispatchEvent(clickEvent); + this._doingKeyboardActivation = false; break; } } @@ -1618,7 +1682,6 @@ var PanelView = class extends AssociatedToNode { * Clear all traces of keyboard navigation happening right now. */ clearNavigation() { - delete this.__navigableElements; let selected = this.selectedElement; if (selected) { selected.blur(); diff --git a/browser/components/customizableui/content/panelUI.inc.xul b/browser/components/customizableui/content/panelUI.inc.xul index 850bafc3cc57..dfb109b34558 100644 --- a/browser/components/customizableui/content/panelUI.inc.xul +++ b/browser/components/customizableui/content/panelUI.inc.xul @@ -9,7 +9,7 @@ position="bottomcenter topright" photon="true" hidden="true"> - + diff --git a/browser/components/customizableui/test/browser.ini b/browser/components/customizableui/test/browser.ini index 63ce1ae9d696..48e9f199cc67 100644 --- a/browser/components/customizableui/test/browser.ini +++ b/browser/components/customizableui/test/browser.ini @@ -175,6 +175,7 @@ subsuite = clipboard [browser_open_from_popup.js] [browser_open_in_lazy_tab.js] [browser_PanelMultiView_focus.js] +[browser_PanelMultiView_keyboard.js] [browser_reload_tab.js] [browser_sidebar_toggle.js] skip-if = verify diff --git a/browser/components/customizableui/test/browser_PanelMultiView_focus.js b/browser/components/customizableui/test/browser_PanelMultiView_focus.js index 8d31d8c5aac8..30bf475b1f61 100644 --- a/browser/components/customizableui/test/browser_PanelMultiView_focus.js +++ b/browser/components/customizableui/test/browser_PanelMultiView_focus.js @@ -11,8 +11,12 @@ const {PanelMultiView} = ChromeUtils.import("resource:///modules/PanelMultiView. let gAnchor; let gPanel; +let gPanelMultiView; let gMainView; let gMainButton; +let gMainSubButton; +let gSubView; +let gSubButton; add_task(async function setup() { let navBar = document.getElementById("nav-bar"); @@ -20,22 +24,32 @@ add_task(async function setup() { // Must be focusable in order for key presses to work. gAnchor.style["-moz-user-focus"] = "normal"; navBar.appendChild(gAnchor); - gPanel = document.createXULElement("panel"); - navBar.appendChild(gPanel); - let panelMultiView = document.createXULElement("panelmultiview"); - panelMultiView.setAttribute("mainViewId", "testMainView"); - gPanel.appendChild(panelMultiView); - gMainView = document.createXULElement("panelview"); - gMainView.id = "testMainView"; - panelMultiView.appendChild(gMainView); - gMainButton = document.createXULElement("button"); - gMainView.appendChild(gMainButton); - let onPress = event => PanelMultiView.openPopup(gPanel, gAnchor, { triggerEvent: event, }); gAnchor.addEventListener("keypress", onPress); gAnchor.addEventListener("click", onPress); + 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); + gMainButton = document.createXULElement("button"); + gMainView.appendChild(gMainButton); + gMainSubButton = document.createXULElement("button"); + gMainView.appendChild(gMainSubButton); + gMainSubButton.addEventListener("command", () => + gPanelMultiView.showSubView("testSubView", gMainSubButton)); + + gSubView = document.createXULElement("panelview"); + gSubView.id = "testSubView"; + gPanelMultiView.appendChild(gSubView); + gSubButton = document.createXULElement("button"); + gSubView.appendChild(gSubButton); registerCleanupFunction(() => { gAnchor.remove(); @@ -64,3 +78,56 @@ add_task(async function testMainViewByClick() { await gCUITestUtils.hidePanelMultiView(gPanel, () => PanelMultiView.hidePopup(gPanel)); }); + +// Activate the subview by pressing a key. Focus should be moved to the first +// button after the Back button. +add_task(async function testSubViewByKeypress() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, + () => gAnchor.click()); + while (document.activeElement != gMainSubButton) { + EventUtils.synthesizeKey("KEY_Tab", {shiftKey: true}); + } + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + EventUtils.synthesizeKey(" "); + await shown; + Assert.equal(document.activeElement, gSubButton, + "Focus on first button after Back button in subview"); + await gCUITestUtils.hidePanelMultiView(gPanel, + () => PanelMultiView.hidePopup(gPanel)); +}); + +// Activate the subview by clicking the mouse. Focus should not be moved +// inside. +add_task(async function testSubViewByClick() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, + () => gAnchor.click()); + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + gMainSubButton.click(); + await shown; + let backButton = gSubView.querySelector(".subviewbutton-back"); + Assert.notEqual(document.activeElement, backButton, + "Focus not on Back button in subview"); + Assert.notEqual(document.activeElement, gSubButton, + "Focus not on button after Back button in subview"); + await gCUITestUtils.hidePanelMultiView(gPanel, + () => PanelMultiView.hidePopup(gPanel)); +}); + +// Test that focus is restored when going back to a previous view. +add_task(async function testBackRestoresFocus() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, + () => gAnchor.click()); + while (document.activeElement != gMainSubButton) { + EventUtils.synthesizeKey("KEY_Tab", {shiftKey: true}); + } + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + EventUtils.synthesizeKey(" "); + await shown; + shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await shown; + Assert.equal(document.activeElement, gMainSubButton, + "Focus on sub button in main view"); + await gCUITestUtils.hidePanelMultiView(gPanel, + () => PanelMultiView.hidePopup(gPanel)); +}); diff --git a/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js b/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js new file mode 100644 index 000000000000..d612d26a5405 --- /dev/null +++ b/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js @@ -0,0 +1,256 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test the keyboard behavior of PanelViews. + */ + +const {PanelMultiView} = ChromeUtils.import("resource:///modules/PanelMultiView.jsm"); + +let gAnchor; +let gPanel; +let gPanelMultiView; +let gMainView; +let gMainButton1; +let gMainMenulist; +let gMainTextbox; +let gMainButton2; +let gMainButton3; +let gMainTabOrder; +let gMainArrowOrder; +let gSubView; +let gSubButton; +let gSubTextarea; + +async function openPopup() { + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + PanelMultiView.openPopup(gPanel, gAnchor, "bottomcenter topright"); + await shown; +} + +async function hidePopup() { + let hidden = BrowserTestUtils.waitForEvent(gPanel, "popuphidden"); + PanelMultiView.hidePopup(gPanel); + await hidden; +} + +async function showSubView() { + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + gPanelMultiView.showSubView(gSubView); + 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_task(async function setup() { + 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); + gMainButton1 = document.createXULElement("button"); + gMainButton1.id = "gMainButton1"; + gMainView.appendChild(gMainButton1); + 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); + gMainTextbox = document.createXULElement("textbox"); + 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); + gMainTabOrder = [gMainButton1, gMainMenulist, gMainTextbox, gMainButton2, + gMainButton3]; + gMainArrowOrder = [gMainButton1, gMainButton2, gMainButton3]; + + 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"; + + 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 pressing space in a textbox inserts a space (instead of trying to +// activate the control). +add_task(async function testSpaceTextbox() { + await openPopup(); + gMainTextbox.focus(); + EventUtils.synthesizeKey("KEY_Home"); + 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(); +}); diff --git a/browser/components/customizableui/test/browser_panel_keyboard_navigation.js b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js index 05d03564fa7e..3b058a04f1aa 100644 --- a/browser/components/customizableui/test/browser_panel_keyboard_navigation.js +++ b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js @@ -91,13 +91,20 @@ add_task(async function testEnterKeyBehaviors() { "First button in help view should be a back button"); // For posterity, check navigating the subview using up/ down arrow keys as well. + // When opening a subview, the first control *after* the Back button gets + // focus. + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal(focusedElement, helpButtons[0], + "The Back button should be focused after navigating upward"); for (let i = helpButtons.length - 1; i >= 0; --i) { let button = helpButtons[i]; if (button.disabled) continue; EventUtils.synthesizeKey("KEY_ArrowUp"); focusedElement = document.commandDispatcher.focusedElement; - Assert.equal(focusedElement, button, "The first button should be focused after navigating upward"); + Assert.equal(focusedElement, button, + "The previous button should be focused after navigating upward"); } // Make sure the back button is in focus again. diff --git a/browser/components/downloads/content/allDownloadsView.js b/browser/components/downloads/content/allDownloadsView.js index dcd04ea10012..01a389e13dd8 100644 --- a/browser/components/downloads/content/allDownloadsView.js +++ b/browser/components/downloads/content/allDownloadsView.js @@ -248,7 +248,7 @@ DownloadsPlacesView.prototype = { let winUtils = window.windowUtils; let nodes = winUtils.nodesFromRect(rlbRect.left, rlbRect.top, 0, rlbRect.width, rlbRect.height, 0, - true, false); + true, false, false); // nodesFromRect returns nodes in z-index order, and for the same z-index // sorts them in inverted DOM order, thus starting from the one that would // be on top. diff --git a/dom/base/DocumentOrShadowRoot.cpp b/dom/base/DocumentOrShadowRoot.cpp index 0f005740c112..0f9be4e6b528 100644 --- a/dom/base/DocumentOrShadowRoot.cpp +++ b/dom/base/DocumentOrShadowRoot.cpp @@ -362,6 +362,7 @@ void DocumentOrShadowRoot::NodesFromRect(float aX, float aY, float aTopSize, float aLeftSize, bool aIgnoreRootScrollFrame, bool aFlushLayout, + bool aOnlyVisible, nsTArray>& aReturn) { // Following the same behavior of elementFromPoint, // we don't return anything if either coord is negative @@ -380,6 +381,9 @@ void DocumentOrShadowRoot::NodesFromRect(float aX, float aY, float aTopSize, if (aIgnoreRootScrollFrame) { options += FrameForPointOption::IgnoreRootScrollFrame; } + if (aOnlyVisible) { + options += FrameForPointOption::OnlyVisible; + } auto flush = aFlushLayout ? FlushLayout::Yes : FlushLayout::No; QueryNodesFromRect(*this, rect, options, flush, Multiple::Yes, aReturn); diff --git a/dom/base/DocumentOrShadowRoot.h b/dom/base/DocumentOrShadowRoot.h index 5980e175c262..d6a837f6511b 100644 --- a/dom/base/DocumentOrShadowRoot.h +++ b/dom/base/DocumentOrShadowRoot.h @@ -120,7 +120,7 @@ class DocumentOrShadowRoot { void NodesFromRect(float aX, float aY, float aTopSize, float aRightSize, float aBottomSize, float aLeftSize, bool aIgnoreRootScrollFrame, bool aFlushLayout, - nsTArray>&); + bool aOnlyVisible, nsTArray>&); /** * This gets fired when the element that an id refers to changes. diff --git a/dom/base/nsDOMWindowUtils.cpp b/dom/base/nsDOMWindowUtils.cpp index 00a5cdb68b3a..9e59d3ac6076 100644 --- a/dom/base/nsDOMWindowUtils.cpp +++ b/dom/base/nsDOMWindowUtils.cpp @@ -1155,7 +1155,8 @@ NS_IMETHODIMP nsDOMWindowUtils::NodesFromRect(float aX, float aY, float aTopSize, float aRightSize, float aBottomSize, float aLeftSize, bool aIgnoreRootScrollFrame, - bool aFlushLayout, nsINodeList** aReturn) { + bool aFlushLayout, bool aOnlyVisible, + nsINodeList** aReturn) { nsCOMPtr doc = GetDocument(); NS_ENSURE_STATE(doc); @@ -1165,7 +1166,8 @@ nsDOMWindowUtils::NodesFromRect(float aX, float aY, float aTopSize, AutoTArray, 8> nodes; doc->NodesFromRect(aX, aY, aTopSize, aRightSize, aBottomSize, aLeftSize, - aIgnoreRootScrollFrame, aFlushLayout, nodes); + aIgnoreRootScrollFrame, aFlushLayout, aOnlyVisible, + nodes); list->SetCapacity(nodes.Length()); for (auto& node : nodes) { list->AppendElement(node->AsContent()); diff --git a/dom/html/HTMLMediaElement.cpp b/dom/html/HTMLMediaElement.cpp index 2e6d570f37c8..08f443ff2822 100644 --- a/dom/html/HTMLMediaElement.cpp +++ b/dom/html/HTMLMediaElement.cpp @@ -4097,19 +4097,7 @@ nsresult HTMLMediaElement::BindToTree(Document* aDocument, nsIContent* aParent, if (IsInComposedDoc()) { // Construct Shadow Root so web content can be hidden in the DOM. AttachAndSetUAShadowRoot(); -#ifdef ANDROID NotifyUAWidgetSetupOrChange(); -#else - // We don't want to call into JS if the website never asks for native - // video controls. - // If controls attribute is set later, controls is constructed lazily - // with the UAWidgetAttributeChanged event. - // This only applies to Desktop because on Fennec we would need to show - // an UI if the video is blocked. - if (Controls()) { - NotifyUAWidgetSetupOrChange(); - } -#endif } mUnboundFromTree = false; diff --git a/dom/html/HTMLVideoElement.cpp b/dom/html/HTMLVideoElement.cpp index 6b125d0948e1..09782dfaf6c7 100644 --- a/dom/html/HTMLVideoElement.cpp +++ b/dom/html/HTMLVideoElement.cpp @@ -548,5 +548,15 @@ void HTMLVideoElement::EndCloningVisually() { } } +void HTMLVideoElement::TogglePictureInPicture(ErrorResult& error) { + // The MozTogglePictureInPicture event is listen for via the + // PictureInPictureChild actor, which is responsible for opening the new + // window and starting the visual clone. + nsresult rv = DispatchEvent(NS_LITERAL_STRING("MozTogglePictureInPicture")); + if (NS_FAILED(rv)) { + error.Throw(rv); + } +} + } // namespace dom } // namespace mozilla diff --git a/dom/html/HTMLVideoElement.h b/dom/html/HTMLVideoElement.h index 59bcbd755ac3..ed2096548af4 100644 --- a/dom/html/HTMLVideoElement.h +++ b/dom/html/HTMLVideoElement.h @@ -145,6 +145,8 @@ class HTMLVideoElement final : public HTMLMediaElement { bool IsCloningElementVisually() const { return !!mVisualCloneTarget; } + void TogglePictureInPicture(ErrorResult& rv); + protected: virtual ~HTMLVideoElement(); diff --git a/dom/interfaces/base/nsIDOMWindowUtils.idl b/dom/interfaces/base/nsIDOMWindowUtils.idl index 0f1b12f0526d..cbfe4cfd60de 100644 --- a/dom/interfaces/base/nsIDOMWindowUtils.idl +++ b/dom/interfaces/base/nsIDOMWindowUtils.idl @@ -746,6 +746,8 @@ interface nsIDOMWindowUtils : nsISupports { * frame when retrieving the element. If false, this method returns * null for coordinates outside of the viewport. * @param aFlushLayout flushes layout if true. Otherwise, no flush occurs. + * @param aOnlyVisible Set to true if you only want nodes that pass a visibility + * hit test. */ NodeList nodesFromRect(in float aX, in float aY, @@ -754,7 +756,8 @@ interface nsIDOMWindowUtils : nsISupports { in float aBottomSize, in float aLeftSize, in boolean aIgnoreRootScrollFrame, - in boolean aFlushLayout); + in boolean aFlushLayout, + in boolean aOnlyVisible); /** diff --git a/dom/media/test/test_cloneElementVisually_no_suspend.html b/dom/media/test/test_cloneElementVisually_no_suspend.html index 648274c377fb..d3a29f5d90b6 100644 --- a/dom/media/test/test_cloneElementVisually_no_suspend.html +++ b/dom/media/test/test_cloneElementVisually_no_suspend.html @@ -56,7 +56,11 @@ add_task(async () => { suspendTimerFired = true; } originalVideo.addEventListener("mozstartvideosuspendtimer", listener); - originalVideo.setVisible(false); + + // Have to do this to access normally-preffed off binding methods for some + // reason. + // See bug 1544257. + SpecialPowers.wrap(originalVideo).setVisible(false); await waitForEventOnce(originalVideo, "ended"); @@ -65,7 +69,10 @@ add_task(async () => { ok(!suspendTimerFired, "mozstartvideosuspendtimer should not have fired."); - originalVideo.setVisible(true); + // Have to do this to access normally-preffed off binding methods for some + // reason. + // See bug 1544257. + SpecialPowers.wrap(originalVideo).setVisible(true); }); await originalVideo.play(); diff --git a/dom/media/tests/mochitest/test_setSinkId.html b/dom/media/tests/mochitest/test_setSinkId.html index 35ac24290a1e..cbf51dbda3f6 100644 --- a/dom/media/tests/mochitest/test_setSinkId.html +++ b/dom/media/tests/mochitest/test_setSinkId.html @@ -30,6 +30,11 @@ info(`Found ${audioDevices.length} output devices`); ok(audioDevices.length > 0, "More than one output device found"); + // Have to do this to access normally-preffed off binding methods for some + // reason. + // See bug 1544257. + audio = SpecialPowers.wrap(audio); + is(audio.sinkId, "", "Initial value is empty string"); const p = audio.setSinkId(audioDevices[0].deviceId); diff --git a/dom/tests/mochitest/chrome/489127.html b/dom/tests/mochitest/chrome/489127.html index 75bc926146b7..f32b41a96a0a 100644 --- a/dom/tests/mochitest/chrome/489127.html +++ b/dom/tests/mochitest/chrome/489127.html @@ -13,20 +13,8 @@ let dwu = window.windowUtils; - /* - NodeList nodesFromRect(in float aX, - in float aY, - in float aTopSize, - in float aRightSize, - in float aBottomSize, - in float aLeftSize, - in boolean aIgnoreRootScrollFrame, - in boolean aFlushLayout); - - */ - function check(x, y, top, right, bottom, left, list) { - let nodes = dwu.nodesFromRect(x, y, top, right, bottom, left, true, false); + let nodes = dwu.nodesFromRect(x, y, top, right, bottom, left, true, false, false); list.push(e.body); list.push(e.html); diff --git a/dom/webidl/HTMLVideoElement.webidl b/dom/webidl/HTMLVideoElement.webidl index 84b372e64909..5d5ecc4970f5 100644 --- a/dom/webidl/HTMLVideoElement.webidl +++ b/dom/webidl/HTMLVideoElement.webidl @@ -69,6 +69,12 @@ partial interface HTMLVideoElement { //