Bug 1289170 - improving keyboard accessibility for dev tools tabbar. r=bgrins
MozReview-Commit-ID: 10WSu9nGYmz
--- a/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
+++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js
@@ -14,23 +14,16 @@ function containsFocus(aDoc, aElm) {
let elm = aDoc.activeElement;
while (elm) {
if (elm === aElm) { return true; }
elm = elm.parentNode;
}
return false;
}
-function testFocus(aDoc, aToolbar, aElm) {
- let id = aElm.id;
- is(aToolbar.getAttribute("aria-activedescendant"), id,
- `Active descendant is set to a new control: ${id}`);
- is(aDoc.activeElement.id, id, "New control is focused");
-}
-
add_task(function* () {
info("Create a test tab and open the toolbox");
let toolbox = yield openNewTabAndToolbox(TEST_URL, "webconsole");
let doc = toolbox.doc;
let toolbar = doc.querySelector(".devtools-tabbar");
let toolbarControls = [...toolbar.querySelectorAll(
".devtools-tab, button")].filter(elm =>
@@ -46,43 +39,43 @@ add_task(function* () {
ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar");
// Move the focus back to the toolbar.
EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
ok(containsFocus(doc, toolbar), "Focus is within the toolbar again");
// Move through the toolbar forward using the right arrow key.
for (let i = 0; i < toolbarControls.length; ++i) {
- testFocus(doc, toolbar, toolbarControls[i]);
+ is(doc.activeElement.id, toolbarControls[i].id, "New control is focused");
if (i < toolbarControls.length - 1) {
EventUtils.synthesizeKey("VK_RIGHT", {});
}
}
// Move the focus away from toolbar to a next focusable element.
EventUtils.synthesizeKey("VK_TAB", {});
ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar");
// Move the focus back to the toolbar.
EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
ok(containsFocus(doc, toolbar), "Focus is within the toolbar again");
// Move through the toolbar backward using the left arrow key.
for (let i = toolbarControls.length - 1; i >= 0; --i) {
- testFocus(doc, toolbar, toolbarControls[i]);
+ is(doc.activeElement.id, toolbarControls[i].id, "New control is focused");
if (i > 0) { EventUtils.synthesizeKey("VK_LEFT", {}); }
}
// Move focus to the 3rd (non-first) toolbar control.
let expectedFocusedControl = toolbarControls[2];
EventUtils.synthesizeKey("VK_RIGHT", {});
EventUtils.synthesizeKey("VK_RIGHT", {});
- testFocus(doc, toolbar, expectedFocusedControl);
+ is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused");
// Move the focus away from toolbar to a next focusable element.
EventUtils.synthesizeKey("VK_TAB", {});
ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar");
// Move the focus back to the toolbar, ensure we land on the last active
// descendant control.
EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
- testFocus(doc, toolbar, expectedFocusedControl);
+ is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused");
});
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -146,16 +146,18 @@ function Toolbox(target, selectedTool, h
this._showDevEditionPromo = this._showDevEditionPromo.bind(this);
this._updateTextboxMenuItems = this._updateTextboxMenuItems.bind(this);
this._onBottomHostMinimized = this._onBottomHostMinimized.bind(this);
this._onBottomHostMaximized = this._onBottomHostMaximized.bind(this);
this._onToolSelectWhileMinimized = this._onToolSelectWhileMinimized.bind(this);
this._onPerformanceFrontEvent = this._onPerformanceFrontEvent.bind(this);
this._onBottomHostWillChange = this._onBottomHostWillChange.bind(this);
this._toggleMinimizeMode = this._toggleMinimizeMode.bind(this);
+ this._onTabbarFocus = this._onTabbarFocus.bind(this);
+ this._onTabbarArrowKeypress = this._onTabbarArrowKeypress.bind(this);
this._target.on("close", this.destroy);
if (!hostType) {
hostType = Services.prefs.getCharPref(this._prefs.LAST_HOST);
}
if (!selectedTool) {
selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL);
@@ -426,17 +428,21 @@ Toolbox.prototype = {
this._applyServiceWorkersTestingSettings();
this._addKeysToWindow();
this._addReloadKeys(shortcuts);
this._addHostListeners(shortcuts);
this._registerOverlays();
if (!this._hostOptions || this._hostOptions.zoom === true) {
ZoomKeys.register(this.win);
}
- this._setToolbarKeyboardNavigation();
+
+ this.tabbar = this.doc.querySelector(".devtools-tabbar");
+ this.tabbar.addEventListener("focus", this._onTabbarFocus, true);
+ this.tabbar.addEventListener("click", this._onTabbarFocus, true);
+ this.tabbar.addEventListener("keypress", this._onTabbarArrowKeypress);
this.webconsolePanel = this.doc.querySelector("#toolbox-panel-webconsole");
this.webconsolePanel.height = Services.prefs.getIntPref(SPLITCONSOLE_HEIGHT_PREF);
this.webconsolePanel.addEventListener("resize", this._saveSplitConsoleHeight);
let buttonsPromise = this._buildButtons();
this._pingTelemetry();
@@ -751,16 +757,19 @@ Toolbox.prototype = {
return;
}
// Bottom-type host can be minimized, add a button for this.
if (this.hostType == Toolbox.HostType.BOTTOM) {
let minimizeBtn = this.doc.createElementNS(HTML_NS, "button");
minimizeBtn.id = "toolbox-dock-bottom-minimize";
minimizeBtn.className = "devtools-button";
+ /* Bug 1177463 - The minimize button is currently hidden until we agree on
+ the UI for it, and until bug 1173849 is fixed too. */
+ minimizeBtn.setAttribute("hidden", "true");
minimizeBtn.addEventListener("click", this._toggleMinimizeMode);
dockBox.appendChild(minimizeBtn);
// Show the button in its maximized state.
this._onBottomHostMaximized();
// Update the label and icon when the state changes.
this._host.on("minimized", this._onBottomHostMinimized);
@@ -838,95 +847,92 @@ Toolbox.prototype = {
_toggleMinimizeMode: function () {
if (this.hostType !== Toolbox.HostType.BOTTOM) {
return;
}
// Calculate the height to which the host should be minimized so the
// tabbar is still visible.
- let toolbarHeight = this.doc.querySelector(".devtools-tabbar")
- .getBoxQuads({box: "content"})[0]
- .bounds.height;
+ let toolbarHeight = this.tabbar.getBoxQuads({box: "content"})[0].bounds
+ .height;
this._host.toggleMinimizeMode(toolbarHeight);
},
/**
* Add tabs to the toolbox UI for registered tools
*/
_buildTabs: function () {
for (let definition of gDevTools.getToolDefinitionArray()) {
this._buildTabForTool(definition);
}
},
/**
- * Sets up keyboard navigation with and within the dev tools toolbar.
+ * Get all dev tools tab bar focusable elements. These are visible elements
+ * such as buttons or elements with tabindex.
*/
- _setToolbarKeyboardNavigation() {
- let toolbar = this.doc.querySelector(".devtools-tabbar");
- // Set and track aria-activedescendant to indicate which control is
- // currently focused within the toolbar (for accessibility purposes).
- toolbar.addEventListener("focus", event => {
- let { target, rangeParent } = event;
- let control, controlID = toolbar.getAttribute("aria-activedescendant");
+ get tabbarFocusableElms() {
+ return [...this.tabbar.querySelectorAll(
+ "[tabindex]:not([hidden]), button:not([hidden])")];
+ },
- if (controlID) {
- control = this.doc.getElementById(controlID);
- }
- if (rangeParent || !control) {
- // If range parent is present, the focused is moved within the toolbar,
- // simply updating aria-activedescendant. Or if aria-activedescendant is
- // not available, set it to target.
- toolbar.setAttribute("aria-activedescendant", target.id);
- } else {
- // When range parent is not present, we focused into the toolbar, move
- // focus to current aria-activedescendant.
- event.preventDefault();
- control.focus();
- }
- }, true);
+ /**
+ * Reset tabindex attributes across all focusable elements inside the tabbar.
+ * Only have one element with tabindex=0 at a time to make sure that tabbing
+ * results in navigating away from the tabbar container.
+ * @param {FocusEvent} event
+ */
+ _onTabbarFocus: function (event) {
+ this.tabbarFocusableElms.forEach(elm =>
+ elm.setAttribute("tabindex", event.target === elm ? "0" : "-1"));
+ },
- toolbar.addEventListener("keypress", event => {
- let { key, target } = event;
- let win = this.win;
- let elm, type;
- if (key === "Tab") {
- // Tabbing when toolbar or its contents are focused should move focus to
- // next/previous focusable element relative to toolbar itself.
- if (event.shiftKey) {
- elm = toolbar;
- type = Services.focus.MOVEFOCUS_BACKWARD;
- } else {
- // To move focus to next element following the toolbar, relative
- // element needs to be the last element in its subtree.
- let last = toolbar.lastChild;
- while (last && last.lastChild) {
- last = last.lastChild;
- }
- elm = last;
- type = Services.focus.MOVEFOCUS_FORWARD;
- }
- } else if (key === "ArrowLeft") {
- // Using left arrow key inside toolbar should move focus to previous
- // toolbar control.
- elm = target;
- type = Services.focus.MOVEFOCUS_BACKWARD;
- } else if (key === "ArrowRight") {
- // Using right arrow key inside toolbar should move focus to next
- // toolbar control.
- elm = target;
- type = Services.focus.MOVEFOCUS_FORWARD;
- } else {
- // Ignore all other keys.
+ /**
+ * On left/right arrow press, attempt to move the focus inside the tabbar to
+ * the previous/next focusable element.
+ * @param {KeyboardEvent} event
+ */
+ _onTabbarArrowKeypress: function (event) {
+ let { key, target } = event;
+ let focusableElms = this.tabbarFocusableElms;
+ let curIndex = focusableElms.indexOf(target);
+
+ if (curIndex === -1) {
+ console.warn(target + " is not found among Developer Tools tab bar " +
+ "focusable elements. It needs to either be a button or have " +
+ "tabindex. If it is intended to be hidden, 'hidden' attribute must " +
+ "be used.");
+ return;
+ }
+
+ let newTarget;
+
+ if (key === "ArrowLeft") {
+ // Do nothing if already at the beginning.
+ if (curIndex === 0) {
return;
}
- event.preventDefault();
- Services.focus.moveFocus(win, elm, type, 0);
- });
+ newTarget = focusableElms[curIndex - 1];
+ } else if (key === "ArrowRight") {
+ // Do nothing if already at the end.
+ if (curIndex === focusableElms.length - 1) {
+ return;
+ }
+ newTarget = focusableElms[curIndex + 1];
+ } else {
+ return;
+ }
+
+ focusableElms.forEach(elm =>
+ elm.setAttribute("tabindex", newTarget === elm ? "0" : "-1"));
+ newTarget.focus();
+
+ event.preventDefault();
+ event.stopPropagation();
},
/**
* Add buttons to the UI as specified in the devtools.toolbox.toolbarSpec pref
*/
_buildButtons: function () {
if (!this.target.isAddon || this.target.isWebExtension) {
this._buildPickerButton();
@@ -1094,16 +1100,17 @@ Toolbox.prototype = {
let radio = this.doc.createElement("radio");
// The radio element is not being used in the conventional way, thus
// the devtools-tab class replaces the radio XBL binding with its base
// binding (the control-item binding).
radio.className = "devtools-tab";
radio.id = "toolbox-tab-" + id;
radio.setAttribute("toolid", id);
+ radio.setAttribute("tabindex", "0");
radio.setAttribute("ordinal", toolDefinition.ordinal);
radio.setAttribute("tooltiptext", toolDefinition.tooltip);
if (toolDefinition.invertIconForLightTheme) {
radio.setAttribute("icon-invertable", "light-theme");
} else if (toolDefinition.invertIconForDarkTheme) {
radio.setAttribute("icon-invertable", "dark-theme");
}
@@ -2044,16 +2051,19 @@ Toolbox.prototype = {
if (this.webconsolePanel) {
this._saveSplitConsoleHeight();
this.webconsolePanel.removeEventListener("resize",
this._saveSplitConsoleHeight);
}
this.closeButton.removeEventListener("click", this.destroy, true);
this.textboxContextMenuPopup.removeEventListener("popupshowing",
this._updateTextboxMenuItems, true);
+ this.tabbar.removeEventListener("focus", this._onTabbarFocus, true);
+ this.tabbar.removeEventListener("click", this._onTabbarFocus, true);
+ this.tabbar.removeEventListener("keypress", this._onTabbarArrowKeypress);
let outstanding = [];
for (let [id, panel] of this._toolPanels) {
try {
gDevTools.emit(id + "-destroy", this, panel);
this.emit(id + "-destroy", panel);
outstanding.push(panel.destroy());
--- a/devtools/client/themes/toolbox.css
+++ b/devtools/client/themes/toolbox.css
@@ -65,17 +65,16 @@
min-height: 24px;
max-width: 100px;
margin: 0;
padding: 0;
border-style: solid;
border-width: 0;
border-inline-start-width: 1px;
-moz-box-align: center;
- -moz-user-focus: normal;
-moz-box-flex: 1;
}
/* Save space on the tab-strip in Firebug theme */
.theme-firebug .devtools-tab {
-moz-box-flex: initial;
}
@@ -212,17 +211,16 @@
margin: 0 8px;
}
/* Toolbox controls */
#toolbox-controls > button,
#toolbox-dock-buttons > button {
-moz-appearance: none;
- -moz-user-focus: normal;
border: none;
margin: 0 4px;
min-width: 16px;
width: 16px;
}
/* Save space in Firebug theme */
.theme-firebug #toolbox-controls button {
@@ -242,22 +240,16 @@
#toolbox-dock-side::before {
background-image: var(--dock-side-image);
}
#toolbox-dock-window::before {
background-image: var(--dock-undock-image);
}
-#toolbox-dock-bottom-minimize {
- /* Bug 1177463 - The minimize button is currently hidden until we agree on
- the UI for it, and until bug 1173849 is fixed too. */
- display: none;
-}
-
#toolbox-dock-bottom-minimize::before {
background-image: url("chrome://devtools/skin/images/dock-bottom-minimize@2x.png");
}
#toolbox-dock-bottom-minimize.minimized::before {
background-image: url("chrome://devtools/skin/images/dock-bottom-maximize@2x.png");
}
@@ -271,17 +263,16 @@
}
/* Command buttons */
.command-button {
padding: 0;
margin: 0;
position: relative;
- -moz-user-focus: normal;
}
.command-button::before {
opacity: 0.7;
}
.command-button:hover {
background-color: var(--toolbar-tab-hover);