Commit 61bc1b56 authored by John Lee's avatar John Lee Committed by Commit Bot

WebUI Tab Strip: Add aria-labels and roles to elements

- Add more accessible labels for title and alert states.
- Move the focusable target from TabElement's host to inner #tab
element to allow aria-labelledby to work.
- Add role and tabindex to appropriate elements.
- Does not update focus outline UI.

Screenshot examples of what is read out: https://imgur.com/a/o0Hmu3a

Bug: 1017472
Change-Id: I230e810fadf92749c2ecf824bb92eb4d8c45774d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1877194
Commit-Queue: John Lee <johntlee@chromium.org>
Reviewed-by: default avatarDemetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/master@{#709246}
parent c61aa430
......@@ -2,12 +2,52 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './strings.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {CustomElement} from './custom_element.js';
import {TabAlertState} from './tabs_api_proxy.js';
/** @const {string} */
const MAX_WIDTH = '16px';
/**
* @param {!TabAlertState} alertState
* @return {string}
*/
function getAriaLabel(alertState) {
// The existing labels for alert states currently expects to format itself
// using the title of the tab (eg. "Website - Audio is playing"). The WebUI
// tab strip will provide the title of the tab elsewhere outside of this
// element, so just provide an empty string as the title here. This also
// allows for multiple labels for the same title (eg. "Website - Audio is
// playing - VR is presenting").
switch (alertState) {
case TabAlertState.MEDIA_RECORDING:
return loadTimeData.getStringF('mediaRecording', '');
case TabAlertState.TAB_CAPTURING:
return loadTimeData.getStringF('tabCapturing', '');
case TabAlertState.AUDIO_PLAYING:
return loadTimeData.getStringF('audioPlaying', '');
case TabAlertState.AUDIO_MUTING:
return loadTimeData.getStringF('audioMuting', '');
case TabAlertState.BLUETOOTH_CONNECTED:
return loadTimeData.getStringF('bluetoothConnected', '');
case TabAlertState.USB_CONNECTED:
return loadTimeData.getStringF('usbConnected', '');
case TabAlertState.SERIAL_CONNECTED:
return loadTimeData.getStringF('seriaLConnected', '');
case TabAlertState.PIP_PLAYING:
return loadTimeData.getStringF('pipPlaying', '');
case TabAlertState.DESKTOP_CAPTURING:
return loadTimeData.getStringF('desktopCapturing', '');
case TabAlertState.VR_PRESENTING_IN_HEADSET:
return loadTimeData.getStringF('vrPresenting', '');
default:
return '';
}
}
export class AlertIndicatorElement extends CustomElement {
static get template() {
return `{__html_template__}`;
......@@ -50,6 +90,7 @@ export class AlertIndicatorElement extends CustomElement {
/** @param {!TabAlertState} alertState */
set alertState(alertState) {
this.setAttribute('alert-state_', alertState);
this.setAttribute('aria-label', getAriaLabel(alertState));
this.alertState_ = alertState;
}
......
......@@ -9,7 +9,7 @@
width: var(--tabstrip-tab-width);
}
#dragImage {
#tab {
background: var(--tabstrip-tab-background-color);
border-radius: var(--tabstrip-tab-border-radius);
box-shadow: 0 0 0 1px var(--tabstrip-tab-separator-color);
......@@ -21,7 +21,7 @@
width: 100%;
}
:host([active]) #dragImage {
:host([active]) #tab {
box-shadow: 0 0 0 2px var(--tabstrip-tab-active-border-color);
outline: none;
}
......@@ -258,7 +258,7 @@
/* When being dragged, the contents of the drag image needs to be off-screen
* with nothing else on top or below obscuring it. */
:host([dragging_]) #dragImage {
:host([dragging_]) #tab {
box-shadow: none;
position: absolute;
top: -999px;
......@@ -267,16 +267,17 @@
<div id="dragPlaceholder"></div>
<div id="dragImage">
<div id="tab" role="tab" tabindex="0"
aria-labelledby="titleText alertIndicators">
<header id="title">
<div id="faviconContainer">
<div id="faviconContainer" aria-hidden="true">
<div id="progressSpinner"></div>
<div id="favicon"></div>
<div id="crashedIcon"></div>
<div id="blocked"></div>
</div>
<h2 id="titleText"></h2>
<tabstrip-alert-indicators></tabstrip-alert-indicators>
<tabstrip-alert-indicators id="alertIndicators"></tabstrip-alert-indicators>
<button id="close">
<span id="closeIcon"></span>
</button>
......
......@@ -2,8 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './strings.m.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {getFavicon} from 'chrome://resources/js/icon.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {AlertIndicatorsElement} from './alert_indicators.js';
import {CustomElement} from './custom_element.js';
......@@ -13,6 +16,24 @@ import {TabData, TabNetworkState, TabsApiProxy} from './tabs_api_proxy.js';
export const DEFAULT_ANIMATION_DURATION = 125;
/**
* @param {!TabData} tab
* @return {string}
*/
function getAccessibleTitle(tab) {
const tabTitle = tab.title;
if (tab.crashed) {
return loadTimeData.getStringF('tabCrashed', tabTitle);
}
if (tab.networkState === TabNetworkState.ERROR) {
return loadTimeData.getStringF('tabNetworkError', tabTitle);
}
return tabTitle;
}
export class TabElement extends CustomElement {
static get template() {
return `{__html_template__}`;
......@@ -31,11 +52,12 @@ export class TabElement extends CustomElement {
/** @private {!HTMLElement} */
this.closeButtonEl_ =
/** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#close'));
this.closeButtonEl_.setAttribute(
'aria-label', loadTimeData.getString('closeTab'));
/** @private {!HTMLElement} */
this.dragImage_ =
/** @type {!HTMLElement} */ (
this.shadowRoot.querySelector('#dragImage'));
this.tabEl_ =
/** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#tab'));
/** @private {!HTMLElement} */
this.faviconEl_ =
......@@ -63,8 +85,8 @@ export class TabElement extends CustomElement {
this.titleTextEl_ = /** @type {!HTMLElement} */ (
this.shadowRoot.querySelector('#titleText'));
this.addEventListener('click', this.onClick_.bind(this));
this.addEventListener('contextmenu', this.onContextMenu_.bind(this));
this.tabEl_.addEventListener('click', this.onClick_.bind(this));
this.tabEl_.addEventListener('contextmenu', this.onContextMenu_.bind(this));
this.closeButtonEl_.addEventListener('click', this.onClose_.bind(this));
}
......@@ -77,6 +99,7 @@ export class TabElement extends CustomElement {
set tab(tab) {
assert(this.tab_ !== tab);
this.toggleAttribute('active', tab.active);
this.tabEl_.setAttribute('aria-selected', tab.active.toString());
this.toggleAttribute('hide-icon_', !tab.showIcon);
this.toggleAttribute(
'waiting_',
......@@ -94,6 +117,7 @@ export class TabElement extends CustomElement {
if (!this.tab_ || this.tab_.title !== tab.title) {
this.titleTextEl_.textContent = tab.title;
}
this.titleTextEl_.setAttribute('aria-label', getAccessibleTitle(tab));
if (tab.networkState === TabNetworkState.WAITING ||
(tab.networkState === TabNetworkState.LOADING &&
......@@ -120,11 +144,9 @@ export class TabElement extends CustomElement {
this.tab_ = Object.freeze(tab);
}
/**
* @return {!HTMLElement}
*/
/** @return {!HTMLElement} */
getDragImage() {
return this.dragImage_;
return this.tabEl_;
}
/**
......@@ -147,7 +169,10 @@ export class TabElement extends CustomElement {
}
}
/** @private */
/**
* @param {!Event} event
* @private
*/
onContextMenu_(event) {
event.preventDefault();
......
......@@ -32,7 +32,7 @@
</style>
</head>
<body>
<tabstrip-tab-list></tabstrip-tab-list>
<tabstrip-tab-list role="tablist"></tabstrip-tab-list>
<script src="tab_list.js" type="module"></script>
</body>
</html>
......@@ -29,12 +29,15 @@
#include "chrome/browser/ui/tabs/tab_utils.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/browser/ui/webui/favicon_source.h"
#include "chrome/browser/ui/webui/localized_string.h"
#include "chrome/browser/ui/webui/tab_strip/tab_strip_ui_layout.h"
#include "chrome/browser/ui/webui/theme_handler.h"
#include "chrome/common/webui_url_constants.h"
#include "chrome/grit/generated_resources.h"
#include "chrome/grit/tab_strip_resources.h"
#include "chrome/grit/tab_strip_resources_map.h"
#include "components/favicon_base/favicon_url_parser.h"
#include "components/strings/grit/components_strings.h"
#include "content/public/browser/favicon_status.h"
#include "content/public/browser/navigation_entry.h"
#include "content/public/browser/url_data_source.h"
......@@ -454,6 +457,23 @@ TabStripUI::TabStripUI(content::WebUI* web_ui)
html_source->AddBoolean(
"showDemoOptions",
base::FeatureList::IsEnabled(features::kWebUITabStripDemoOptions));
static constexpr LocalizedString kStrings[] = {
{"closeTab", IDS_ACCNAME_CLOSE},
{"tabCrashed", IDS_TAB_AX_LABEL_CRASHED_FORMAT},
{"tabNetworkError", IDS_TAB_AX_LABEL_NETWORK_ERROR_FORMAT},
{"audioPlaying", IDS_TAB_AX_LABEL_AUDIO_PLAYING_FORMAT},
{"usbConnected", IDS_TAB_AX_LABEL_USB_CONNECTED_FORMAT},
{"bluetoothConnected", IDS_TAB_AX_LABEL_BLUETOOTH_CONNECTED_FORMAT},
{"serialConnected", IDS_TAB_AX_LABEL_SERIAL_CONNECTED_FORMAT},
{"mediaRecording", IDS_TAB_AX_LABEL_MEDIA_RECORDING_FORMAT},
{"audioMuting", IDS_TAB_AX_LABEL_AUDIO_MUTING_FORMAT},
{"tabCapturing", IDS_TAB_AX_LABEL_DESKTOP_CAPTURING_FORMAT},
{"pipPlaying", IDS_TAB_AX_LABEL_PIP_PLAYING_FORMAT},
{"desktopCapturing", IDS_TAB_AX_LABEL_DESKTOP_CAPTURING_FORMAT},
{"vrPresenting", IDS_TAB_AX_LABEL_VR_PRESENTING},
};
AddLocalizedStringsBulk(html_source, kStrings, base::size(kStrings));
html_source->UseStringsJs();
content::WebUIDataSource::Add(profile, html_source);
......
......@@ -95,7 +95,8 @@ class TabStripUIBrowserTest : public InProcessBrowserTest {
// static
const std::string TabStripUIBrowserTest::tab_query_js(
"document.querySelector('tabstrip-tab-list')"
" .shadowRoot.querySelector('tabstrip-tab')");
" .shadowRoot.querySelector('tabstrip-tab')"
" .shadowRoot.querySelector('#tab')");
IN_PROC_BROWSER_TEST_F(TabStripUIBrowserTest, ActivatingTabClosesEmbedder) {
const std::string activate_tab_js = tab_query_js + ".click()";
......
......@@ -147,6 +147,7 @@ suite('TabList', () => {
test('adds a new tab element when a tab is added in same window', () => {
const appendedTab = {
active: false,
alertStates: [],
id: 3,
index: 3,
......@@ -158,6 +159,7 @@ suite('TabList', () => {
assertEquals(tabElements[tabs.length].tab, appendedTab);
const prependedTab = {
active: false,
alertStates: [],
id: 4,
index: 0,
......@@ -211,6 +213,7 @@ suite('TabList', () => {
test('adds a pinned tab to its designated container', () => {
webUIListenerCallback('tab-created', {
active: false,
alertStates: [],
index: 0,
title: 'New pinned tab',
......
......@@ -5,6 +5,7 @@
import 'chrome://tab-strip/tab.js';
import {getFavicon} from 'chrome://resources/js/icon.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {TabStripEmbedderProxy} from 'chrome://tab-strip/tab_strip_embedder_proxy.js';
import {TabNetworkState, TabsApiProxy} from 'chrome://tab-strip/tabs_api_proxy.js';
......@@ -17,13 +18,22 @@ suite('Tab', function() {
let tabElement;
const tab = {
active: false,
alertStates: [],
id: 1001,
networkState: TabNetworkState.NONE,
title: 'My title',
};
const strings = {
closeTab: 'Close tab',
tabCrashed: '$1 has crashed',
tabNetworkError: '$1 has a network error',
};
setup(() => {
loadTimeData.overrideValues(strings);
document.body.innerHTML = '';
testTabStripEmbedderProxy = new TestTabStripEmbedderProxy();
......@@ -65,6 +75,19 @@ suite('Tab', function() {
assertFalse(tabElement.hasAttribute('active'));
});
test('sets [aria-selected] attribute when active', () => {
tabElement.tab = Object.assign({}, tab, {active: true});
assertEquals(
'true',
tabElement.shadowRoot.querySelector('#tab').getAttribute(
'aria-selected'));
tabElement.tab = Object.assign({}, tab, {active: false});
assertEquals(
'false',
tabElement.shadowRoot.querySelector('#tab').getAttribute(
'aria-selected'));
});
test('hides entire favicon container when showIcon is false', () => {
// disable transitions
tabElement.style.setProperty('--tabstrip-tab-transition-duration', '0ms');
......@@ -158,7 +181,7 @@ suite('Tab', function() {
});
test('clicking on the element activates the tab', () => {
tabElement.click();
tabElement.shadowRoot.querySelector('#tab').click();
return testTabsApiProxy.whenCalled('activateTab', tabId => {
assertEquals(tabId, tab.id);
});
......@@ -247,15 +270,14 @@ suite('Tab', function() {
test('getting the drag image grabs the contents', () => {
assertEquals(
tabElement.getDragImage(),
tabElement.shadowRoot.querySelector('#dragImage'));
tabElement.getDragImage(), tabElement.shadowRoot.querySelector('#tab'));
});
test('has custom context menu', async () => {
let event = new Event('contextmenu');
event.clientX = 1;
event.clientY = 2;
tabElement.dispatchEvent(event);
tabElement.shadowRoot.querySelector('#tab').dispatchEvent(event);
const contextMenuArgs =
await testTabStripEmbedderProxy.whenCalled('showTabContextMenu');
......@@ -266,7 +288,28 @@ suite('Tab', function() {
test('activating closes WebUI container', () => {
assertEquals(testTabStripEmbedderProxy.getCallCount('closeContainer'), 0);
tabElement.click();
tabElement.shadowRoot.querySelector('#tab').click();
assertEquals(testTabStripEmbedderProxy.getCallCount('closeContainer'), 1);
});
test('sets an accessible title', () => {
const titleTextElement = tabElement.shadowRoot.querySelector('#titleText');
assertEquals(titleTextElement.getAttribute('aria-label'), tab.title);
tabElement.tab = Object.assign({}, tab, {
crashed: true,
title: 'My tab',
});
assertEquals(
titleTextElement.getAttribute('aria-label'), 'My tab has crashed');
tabElement.tab = Object.assign({}, tab, {
crashed: false,
networkState: TabNetworkState.ERROR,
title: 'My tab',
});
assertEquals(
titleTextElement.getAttribute('aria-label'),
'My tab has a network error');
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment