Skip to content

Commit 618aa77

Browse files
committed
Bug 1975799 - Capture focus events in the IP Protection panel to allow keyboard navigation. r=ip-protection-reviewers,rking,kpatenio
- Updates the PanelMultiView TreeWalker filter to accept elements with `data-captures-focus="true"` and handle those elements like iframes, delegating focus handling to the element. - Captures focus for `ipprotection-content` and adds a focus method to make sure the connection toggle is the first element focused. - Adds a key handler in `ipprotection-content` to allow arrow navigation. - Delegate focus in `ipprotection-signedout` to allow focusing its contents. Differential Revision: https://phabricator.services.mozilla.com/D258253
1 parent d7a6dd4 commit 618aa77

File tree

9 files changed

+267
-5
lines changed

9 files changed

+267
-5
lines changed

browser/components/customizableui/PanelMultiView.sys.mjs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,15 +1595,17 @@ export var PanelView = class extends AssociatedToNode {
15951595
localName == "a" ||
15961596
localName == "moz-toggle" ||
15971597
node.classList.contains("text-link") ||
1598-
(!arrowKey && isNavigableWithTabOnly)
1598+
(!arrowKey && isNavigableWithTabOnly) ||
1599+
node.dataset?.capturesFocus === "true"
15991600
) {
16001601
// Set the tabindex attribute to make sure the node is focusable.
16011602
// Don't do this for browser and iframe elements because this breaks
16021603
// tabbing behavior. They're already focusable anyway.
16031604
if (
16041605
localName != "browser" &&
16051606
localName != "iframe" &&
1606-
!node.hasAttribute("tabindex")
1607+
!node.hasAttribute("tabindex") &&
1608+
node.dataset?.capturesFocus !== "true"
16071609
) {
16081610
node.setAttribute("tabindex", "-1");
16091611
}
@@ -1779,9 +1781,14 @@ export var PanelView = class extends AssociatedToNode {
17791781
focus = null;
17801782
}
17811783

1782-
// Some panels contain embedded documents. We can't manage
1783-
// keyboard navigation within those.
1784-
if (focus && (focus.tagName == "browser" || focus.tagName == "iframe")) {
1784+
// Some panels contain embedded documents or need to capture focus events.
1785+
// We can't manage keyboard navigation within those.
1786+
if (
1787+
focus &&
1788+
(focus.tagName == "browser" ||
1789+
focus.tagName == "iframe" ||
1790+
focus.dataset?.capturesFocus === "true")
1791+
) {
17851792
return;
17861793
}
17871794

browser/components/customizableui/test/browser_PanelMultiView_keyboard.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ let gBrowserBrowser;
3434
let gIframeView;
3535
let gIframeIframe;
3636
let gToggle;
37+
let gComponentView;
38+
let gShadowRoot;
39+
let gShadowRootButtonA;
40+
let gShadowRootButtonB;
3741

3842
async function openPopup() {
3943
let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown");
@@ -207,6 +211,24 @@ add_setup(async function () {
207211
gIframeIframe.setAttribute("src", kEmbeddedDocUrl);
208212
gIframeView.appendChild(gIframeIframe);
209213

214+
gComponentView = document.createXULElement("panelview");
215+
gComponentView.id = "testComponentView";
216+
gPanelMultiView.appendChild(gComponentView);
217+
// Shadow root that delegates focus with multiple buttons
218+
gShadowRoot = document.createElement("section");
219+
gShadowRoot.id = "gShadowRoot";
220+
gShadowRoot.dataset.navigableWithTabOnly = "true";
221+
gShadowRoot.attachShadow({ mode: "open", delegatesFocus: true });
222+
gShadowRootButtonA = document.createElement("moz-button");
223+
gShadowRootButtonA.id = "gShadowRootButtonA";
224+
gShadowRootButtonA.label = "Button A";
225+
gShadowRootButtonB = document.createElement("moz-button");
226+
gShadowRootButtonB.id = "gShadowRootButtonB";
227+
gShadowRootButtonB.label = "Button B";
228+
gShadowRoot.shadowRoot.appendChild(gShadowRootButtonA);
229+
gShadowRoot.shadowRoot.appendChild(gShadowRootButtonB);
230+
gComponentView.appendChild(gShadowRoot);
231+
210232
registerCleanupFunction(() => {
211233
gAnchor.remove();
212234
gPanel.remove();
@@ -580,3 +602,28 @@ add_task(async function testMozToggle() {
580602
is(gToggle.pressed, false, "Toggle pressed state changes via enter.");
581603
await hidePopup();
582604
});
605+
606+
// Test that tab key is not overridden in elements that capture focus.
607+
add_task(async function testTabCapturesFocus() {
608+
await openPopup();
609+
await showSubView(gComponentView);
610+
611+
let backButton = gComponentView.querySelector(".subviewbutton-back");
612+
backButton.id = "shadowBack";
613+
await expectFocusAfterKey("Tab", backButton);
614+
615+
// Only Button A can be navigated to before looping to back button.
616+
await expectFocusAfterKey("Tab", gShadowRootButtonA);
617+
618+
await expectFocusAfterKey("Tab", backButton);
619+
620+
gShadowRoot.dataset.capturesFocus = "true";
621+
622+
// Both buttons can be focused before looping.
623+
await expectFocusAfterKey("Tab", gShadowRootButtonA);
624+
await expectFocusAfterKey("Tab", gShadowRootButtonB);
625+
626+
await expectFocusAfterKey("Tab", backButton);
627+
628+
await hidePopup();
629+
});

browser/components/ipprotection/IPProtection.sys.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ class IPProtectionWidget {
172172
}
173173
}
174174

175+
/**
176+
* Get the IPProtectionPanel for q given window.
177+
*
178+
* @param {Window} window - which window to get the panel for.
179+
* @returns {IPProtectionPanel}
180+
*/
181+
getPanel(window) {
182+
if (!this.#created || !window?.PanelUI) {
183+
return null;
184+
}
185+
186+
return this.#panels.get(window);
187+
}
188+
175189
/**
176190
* Remove all panels content, but maintains state for if the widget is
177191
* re-enabled in the same window.

browser/components/ipprotection/IPProtectionPanel.sys.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ export class IPProtectionPanel {
195195
);
196196
this.panel = contentEl;
197197

198+
contentEl.dataset.capturesFocus = "true";
199+
198200
this.#addPanelListeners(ownerDocument);
199201

200202
panelView.appendChild(contentEl);

browser/components/ipprotection/content/ipprotection-content.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const DEFAULT_TIME_CONNECTED = "00:00:00";
1717
export default class IPProtectionContentElement extends MozLitElement {
1818
static queries = {
1919
headerEl: "ipprotection-header",
20+
signedOutEl: "ipprotection-signedout",
2021
statusCardEl: "#status-card",
2122
connectionTitleEl: "#connection-title",
2223
connectionToggleEl: "#connection-toggle",
@@ -32,15 +33,21 @@ export default class IPProtectionContentElement extends MozLitElement {
3233
super();
3334

3435
this.state = {};
36+
37+
this.keyListener = this.#keyListener.bind(this);
3538
}
3639

3740
connectedCallback() {
3841
super.connectedCallback();
3942
this.dispatchEvent(new CustomEvent("IPProtection:Init", { bubbles: true }));
43+
44+
this.addEventListener("keydown", this.keyListener, { capture: true });
4045
}
4146

4247
disconnectedCallback() {
4348
super.disconnectedCallback();
49+
50+
this.removeEventListener("keydown", this.keyListener, { capture: true });
4451
}
4552

4653
handleClickSupportLink(event) {
@@ -69,6 +76,38 @@ export default class IPProtectionContentElement extends MozLitElement {
6976
// TODO: Handle click of Upgrade button - Bug 1975317
7077
}
7178

79+
focus() {
80+
if (!this.state.isSignedIn) {
81+
this.signedOutEl?.focus();
82+
} else {
83+
this.connectionToggleEl?.focus();
84+
}
85+
}
86+
87+
#keyListener(event) {
88+
let keyCode = event.code;
89+
switch (keyCode) {
90+
case "ArrowUp":
91+
// Intentional fall-through
92+
case "ArrowDown": {
93+
event.stopPropagation();
94+
event.preventDefault();
95+
96+
let direction =
97+
keyCode == "ArrowDown"
98+
? Services.focus.MOVEFOCUS_FORWARD
99+
: Services.focus.MOVEFOCUS_BACKWARD;
100+
Services.focus.moveFocus(
101+
window,
102+
null,
103+
direction,
104+
Services.focus.FLAG_BYKEY
105+
);
106+
break;
107+
}
108+
}
109+
}
110+
72111
statusCardTemplate() {
73112
const statusCardL10nId = this.state.isProtectionEnabled
74113
? "ipprotection-connection-status-on"

browser/components/ipprotection/content/ipprotection-signedout.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
66
import { html } from "chrome://global/content/vendor/lit.all.mjs";
77

88
export default class IPProtectionSignedOutContentElement extends MozLitElement {
9+
static shadowRootOptions = {
10+
...MozLitElement.shadowRootOptions,
11+
delegatesFocus: true,
12+
};
13+
914
constructor() {
1015
super();
1116
}

browser/components/ipprotection/tests/browser/browser.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ prefs = [
1313

1414
["browser_ipprotection_header.js"]
1515

16+
["browser_ipprotection_keyboard_navigation.js"]
17+
1618
["browser_ipprotection_panel.js"]
1719

1820
["browser_ipprotection_toolbar.js"]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
"use strict";
6+
7+
// Borrowed from browser_PanelMultiView_keyboard.js
8+
async function expectFocusAfterKey(aKey, aFocus) {
9+
let res = aKey.match(/^(Shift\+)?(.+)$/);
10+
let shift = Boolean(res[1]);
11+
let key;
12+
if (res[2].length == 1) {
13+
key = res[2]; // Character.
14+
} else {
15+
key = "KEY_" + res[2]; // Tab, ArrowRight, etc.
16+
}
17+
info("Waiting for focus on " + aFocus.id);
18+
let focused = BrowserTestUtils.waitForEvent(aFocus, "focus");
19+
EventUtils.synthesizeKey(key, { shiftKey: shift });
20+
await focused;
21+
ok(true, aFocus.id + " focused after " + aKey + " pressed");
22+
}
23+
24+
/**
25+
* Tests that the panel can be navigated with Tab and Arrow keys.
26+
*/
27+
add_task(async function test_keyboard_navigation_in_panel() {
28+
let content = await openPanel({
29+
isSignedIn: true,
30+
});
31+
32+
Assert.ok(
33+
BrowserTestUtils.isVisible(content),
34+
"ipprotection-content component should be present"
35+
);
36+
37+
await expectFocusAfterKey("Tab", content.connectionToggleEl);
38+
await expectFocusAfterKey("Tab", content.upgradeEl.querySelector("a"));
39+
await expectFocusAfterKey(
40+
"Tab",
41+
content.upgradeEl.querySelector("#upgrade-vpn-button")
42+
);
43+
await expectFocusAfterKey("Tab", content.headerEl.helpButtonEl);
44+
// Loop back around
45+
await expectFocusAfterKey("Tab", content.connectionToggleEl);
46+
47+
await expectFocusAfterKey("ArrowDown", content.upgradeEl.querySelector("a"));
48+
await expectFocusAfterKey(
49+
"ArrowDown",
50+
content.upgradeEl.querySelector("#upgrade-vpn-button")
51+
);
52+
await expectFocusAfterKey("ArrowDown", content.headerEl.helpButtonEl);
53+
// Loop back around
54+
await expectFocusAfterKey("ArrowDown", content.connectionToggleEl);
55+
56+
// Loop backwards
57+
await expectFocusAfterKey("Shift+Tab", content.headerEl.helpButtonEl);
58+
59+
await closePanel();
60+
});

browser/components/ipprotection/tests/browser/head.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
/* Any copyright is dedicated to the Public Domain.
22
* http://creativecommons.org/publicdomain/zero/1.0/ */
33

4+
const { IPProtectionPanel } = ChromeUtils.importESModule(
5+
"resource:///modules/ipprotection/IPProtectionPanel.sys.mjs"
6+
);
7+
8+
const { IPProtectionWidget } = ChromeUtils.importESModule(
9+
"resource:///modules/ipprotection/IPProtection.sys.mjs"
10+
);
11+
12+
const { IPProtection } = ChromeUtils.importESModule(
13+
"resource:///modules/ipprotection/IPProtection.sys.mjs"
14+
);
15+
416
// Adapted from devtools/client/performance-new/test/browser/helpers.js
517
function waitForPanelEvent(document, eventName) {
618
return BrowserTestUtils.waitForEvent(document, eventName, false, event => {
@@ -11,3 +23,77 @@ function waitForPanelEvent(document, eventName) {
1123
});
1224
}
1325
/* exported waitForPanelEvent */
26+
27+
const defaultState = new IPProtectionPanel().state;
28+
29+
/**
30+
* Opens the IP Protection panel with a given state, waits for the content to be ready
31+
* and returns the content element.
32+
*
33+
* @param {object} state - The state to set for the panel.
34+
* @param {Window} win - The window the panel should be opened in.
35+
* @returns {Promise<IPProtectionContentElement>} - The <ipprotection-content> element of the panel.
36+
*/
37+
async function openPanel(state = defaultState, win = window) {
38+
let panel = IPProtection.getPanel(win);
39+
panel.setState(state);
40+
41+
IPProtection.openPanel(win);
42+
43+
let panelShownPromise = waitForPanelEvent(win.document, "popupshown");
44+
let panelInitPromise = BrowserTestUtils.waitForEvent(
45+
win.document,
46+
"IPProtection:Init"
47+
);
48+
await Promise.all([panelShownPromise, panelInitPromise]);
49+
50+
let panelView = PanelMultiView.getViewNode(
51+
win.document,
52+
IPProtectionWidget.PANEL_ID
53+
);
54+
let content = panelView.querySelector(IPProtectionPanel.CONTENT_TAGNAME);
55+
56+
await content.updateComplete;
57+
58+
return content;
59+
}
60+
/* exported openPanel */
61+
62+
/**
63+
* Sets the state of the IP Protection panel and waits for the content to be updated.
64+
*
65+
* @param {object} state - The state to set for the panel.
66+
* @param {Window} win - The window the panel is in.
67+
* @returns {Promise<void>}
68+
*/
69+
async function setPanelState(state = defaultState, win = window) {
70+
let panel = IPProtection.getPanel(win);
71+
panel.setState(state);
72+
73+
let panelView = PanelMultiView.getViewNode(
74+
win.document,
75+
IPProtectionWidget.PANEL_ID
76+
);
77+
let content = panelView.querySelector(IPProtectionPanel.CONTENT_TAGNAME);
78+
79+
await content.updateComplete;
80+
}
81+
82+
/* exported setPanelState */
83+
84+
/**
85+
* Closes the IP Protection panel and resets the state to the default.
86+
*
87+
* @param {Window} win - The window the panel is in.
88+
* @returns {Promise<void>}
89+
*/
90+
async function closePanel(win = window) {
91+
// Reset the state
92+
let panel = IPProtection.getPanel(win);
93+
panel.setState(defaultState);
94+
// Close the panel
95+
let panelHiddenPromise = waitForPanelEvent(win.document, "popuphidden");
96+
panel.close();
97+
await panelHiddenPromise;
98+
}
99+
/* exported closePanel */

0 commit comments

Comments
 (0)