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 @@ ...@@ -2,12 +2,52 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // 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 {CustomElement} from './custom_element.js';
import {TabAlertState} from './tabs_api_proxy.js'; import {TabAlertState} from './tabs_api_proxy.js';
/** @const {string} */ /** @const {string} */
const MAX_WIDTH = '16px'; 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 { export class AlertIndicatorElement extends CustomElement {
static get template() { static get template() {
return `{__html_template__}`; return `{__html_template__}`;
...@@ -50,6 +90,7 @@ export class AlertIndicatorElement extends CustomElement { ...@@ -50,6 +90,7 @@ export class AlertIndicatorElement extends CustomElement {
/** @param {!TabAlertState} alertState */ /** @param {!TabAlertState} alertState */
set alertState(alertState) { set alertState(alertState) {
this.setAttribute('alert-state_', alertState); this.setAttribute('alert-state_', alertState);
this.setAttribute('aria-label', getAriaLabel(alertState));
this.alertState_ = alertState; this.alertState_ = alertState;
} }
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
width: var(--tabstrip-tab-width); width: var(--tabstrip-tab-width);
} }
#dragImage { #tab {
background: var(--tabstrip-tab-background-color); background: var(--tabstrip-tab-background-color);
border-radius: var(--tabstrip-tab-border-radius); border-radius: var(--tabstrip-tab-border-radius);
box-shadow: 0 0 0 1px var(--tabstrip-tab-separator-color); box-shadow: 0 0 0 1px var(--tabstrip-tab-separator-color);
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
width: 100%; width: 100%;
} }
:host([active]) #dragImage { :host([active]) #tab {
box-shadow: 0 0 0 2px var(--tabstrip-tab-active-border-color); box-shadow: 0 0 0 2px var(--tabstrip-tab-active-border-color);
outline: none; outline: none;
} }
...@@ -258,7 +258,7 @@ ...@@ -258,7 +258,7 @@
/* When being dragged, the contents of the drag image needs to be off-screen /* When being dragged, the contents of the drag image needs to be off-screen
* with nothing else on top or below obscuring it. */ * with nothing else on top or below obscuring it. */
:host([dragging_]) #dragImage { :host([dragging_]) #tab {
box-shadow: none; box-shadow: none;
position: absolute; position: absolute;
top: -999px; top: -999px;
...@@ -267,16 +267,17 @@ ...@@ -267,16 +267,17 @@
<div id="dragPlaceholder"></div> <div id="dragPlaceholder"></div>
<div id="dragImage"> <div id="tab" role="tab" tabindex="0"
aria-labelledby="titleText alertIndicators">
<header id="title"> <header id="title">
<div id="faviconContainer"> <div id="faviconContainer" aria-hidden="true">
<div id="progressSpinner"></div> <div id="progressSpinner"></div>
<div id="favicon"></div> <div id="favicon"></div>
<div id="crashedIcon"></div> <div id="crashedIcon"></div>
<div id="blocked"></div> <div id="blocked"></div>
</div> </div>
<h2 id="titleText"></h2> <h2 id="titleText"></h2>
<tabstrip-alert-indicators></tabstrip-alert-indicators> <tabstrip-alert-indicators id="alertIndicators"></tabstrip-alert-indicators>
<button id="close"> <button id="close">
<span id="closeIcon"></span> <span id="closeIcon"></span>
</button> </button>
......
...@@ -2,8 +2,11 @@ ...@@ -2,8 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import './strings.m.js';
import {assert} from 'chrome://resources/js/assert.m.js'; import {assert} from 'chrome://resources/js/assert.m.js';
import {getFavicon} from 'chrome://resources/js/icon.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 {AlertIndicatorsElement} from './alert_indicators.js';
import {CustomElement} from './custom_element.js'; import {CustomElement} from './custom_element.js';
...@@ -13,6 +16,24 @@ import {TabData, TabNetworkState, TabsApiProxy} from './tabs_api_proxy.js'; ...@@ -13,6 +16,24 @@ import {TabData, TabNetworkState, TabsApiProxy} from './tabs_api_proxy.js';
export const DEFAULT_ANIMATION_DURATION = 125; 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 { export class TabElement extends CustomElement {
static get template() { static get template() {
return `{__html_template__}`; return `{__html_template__}`;
...@@ -31,11 +52,12 @@ export class TabElement extends CustomElement { ...@@ -31,11 +52,12 @@ export class TabElement extends CustomElement {
/** @private {!HTMLElement} */ /** @private {!HTMLElement} */
this.closeButtonEl_ = this.closeButtonEl_ =
/** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#close')); /** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#close'));
this.closeButtonEl_.setAttribute(
'aria-label', loadTimeData.getString('closeTab'));
/** @private {!HTMLElement} */ /** @private {!HTMLElement} */
this.dragImage_ = this.tabEl_ =
/** @type {!HTMLElement} */ ( /** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#tab'));
this.shadowRoot.querySelector('#dragImage'));
/** @private {!HTMLElement} */ /** @private {!HTMLElement} */
this.faviconEl_ = this.faviconEl_ =
...@@ -63,8 +85,8 @@ export class TabElement extends CustomElement { ...@@ -63,8 +85,8 @@ export class TabElement extends CustomElement {
this.titleTextEl_ = /** @type {!HTMLElement} */ ( this.titleTextEl_ = /** @type {!HTMLElement} */ (
this.shadowRoot.querySelector('#titleText')); this.shadowRoot.querySelector('#titleText'));
this.addEventListener('click', this.onClick_.bind(this)); this.tabEl_.addEventListener('click', this.onClick_.bind(this));
this.addEventListener('contextmenu', this.onContextMenu_.bind(this)); this.tabEl_.addEventListener('contextmenu', this.onContextMenu_.bind(this));
this.closeButtonEl_.addEventListener('click', this.onClose_.bind(this)); this.closeButtonEl_.addEventListener('click', this.onClose_.bind(this));
} }
...@@ -77,6 +99,7 @@ export class TabElement extends CustomElement { ...@@ -77,6 +99,7 @@ export class TabElement extends CustomElement {
set tab(tab) { set tab(tab) {
assert(this.tab_ !== tab); assert(this.tab_ !== tab);
this.toggleAttribute('active', tab.active); this.toggleAttribute('active', tab.active);
this.tabEl_.setAttribute('aria-selected', tab.active.toString());
this.toggleAttribute('hide-icon_', !tab.showIcon); this.toggleAttribute('hide-icon_', !tab.showIcon);
this.toggleAttribute( this.toggleAttribute(
'waiting_', 'waiting_',
...@@ -94,6 +117,7 @@ export class TabElement extends CustomElement { ...@@ -94,6 +117,7 @@ export class TabElement extends CustomElement {
if (!this.tab_ || this.tab_.title !== tab.title) { if (!this.tab_ || this.tab_.title !== tab.title) {
this.titleTextEl_.textContent = tab.title; this.titleTextEl_.textContent = tab.title;
} }
this.titleTextEl_.setAttribute('aria-label', getAccessibleTitle(tab));
if (tab.networkState === TabNetworkState.WAITING || if (tab.networkState === TabNetworkState.WAITING ||
(tab.networkState === TabNetworkState.LOADING && (tab.networkState === TabNetworkState.LOADING &&
...@@ -120,11 +144,9 @@ export class TabElement extends CustomElement { ...@@ -120,11 +144,9 @@ export class TabElement extends CustomElement {
this.tab_ = Object.freeze(tab); this.tab_ = Object.freeze(tab);
} }
/** /** @return {!HTMLElement} */
* @return {!HTMLElement}
*/
getDragImage() { getDragImage() {
return this.dragImage_; return this.tabEl_;
} }
/** /**
...@@ -147,7 +169,10 @@ export class TabElement extends CustomElement { ...@@ -147,7 +169,10 @@ export class TabElement extends CustomElement {
} }
} }
/** @private */ /**
* @param {!Event} event
* @private
*/
onContextMenu_(event) { onContextMenu_(event) {
event.preventDefault(); event.preventDefault();
......
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
</style> </style>
</head> </head>
<body> <body>
<tabstrip-tab-list></tabstrip-tab-list> <tabstrip-tab-list role="tablist"></tabstrip-tab-list>
<script src="tab_list.js" type="module"></script> <script src="tab_list.js" type="module"></script>
</body> </body>
</html> </html>
...@@ -29,12 +29,15 @@ ...@@ -29,12 +29,15 @@
#include "chrome/browser/ui/tabs/tab_utils.h" #include "chrome/browser/ui/tabs/tab_utils.h"
#include "chrome/browser/ui/ui_features.h" #include "chrome/browser/ui/ui_features.h"
#include "chrome/browser/ui/webui/favicon_source.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/tab_strip/tab_strip_ui_layout.h"
#include "chrome/browser/ui/webui/theme_handler.h" #include "chrome/browser/ui/webui/theme_handler.h"
#include "chrome/common/webui_url_constants.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.h"
#include "chrome/grit/tab_strip_resources_map.h" #include "chrome/grit/tab_strip_resources_map.h"
#include "components/favicon_base/favicon_url_parser.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/favicon_status.h"
#include "content/public/browser/navigation_entry.h" #include "content/public/browser/navigation_entry.h"
#include "content/public/browser/url_data_source.h" #include "content/public/browser/url_data_source.h"
...@@ -454,6 +457,23 @@ TabStripUI::TabStripUI(content::WebUI* web_ui) ...@@ -454,6 +457,23 @@ TabStripUI::TabStripUI(content::WebUI* web_ui)
html_source->AddBoolean( html_source->AddBoolean(
"showDemoOptions", "showDemoOptions",
base::FeatureList::IsEnabled(features::kWebUITabStripDemoOptions)); 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(); html_source->UseStringsJs();
content::WebUIDataSource::Add(profile, html_source); content::WebUIDataSource::Add(profile, html_source);
......
...@@ -95,7 +95,8 @@ class TabStripUIBrowserTest : public InProcessBrowserTest { ...@@ -95,7 +95,8 @@ class TabStripUIBrowserTest : public InProcessBrowserTest {
// static // static
const std::string TabStripUIBrowserTest::tab_query_js( const std::string TabStripUIBrowserTest::tab_query_js(
"document.querySelector('tabstrip-tab-list')" "document.querySelector('tabstrip-tab-list')"
" .shadowRoot.querySelector('tabstrip-tab')"); " .shadowRoot.querySelector('tabstrip-tab')"
" .shadowRoot.querySelector('#tab')");
IN_PROC_BROWSER_TEST_F(TabStripUIBrowserTest, ActivatingTabClosesEmbedder) { IN_PROC_BROWSER_TEST_F(TabStripUIBrowserTest, ActivatingTabClosesEmbedder) {
const std::string activate_tab_js = tab_query_js + ".click()"; const std::string activate_tab_js = tab_query_js + ".click()";
......
...@@ -147,6 +147,7 @@ suite('TabList', () => { ...@@ -147,6 +147,7 @@ suite('TabList', () => {
test('adds a new tab element when a tab is added in same window', () => { test('adds a new tab element when a tab is added in same window', () => {
const appendedTab = { const appendedTab = {
active: false,
alertStates: [], alertStates: [],
id: 3, id: 3,
index: 3, index: 3,
...@@ -158,6 +159,7 @@ suite('TabList', () => { ...@@ -158,6 +159,7 @@ suite('TabList', () => {
assertEquals(tabElements[tabs.length].tab, appendedTab); assertEquals(tabElements[tabs.length].tab, appendedTab);
const prependedTab = { const prependedTab = {
active: false,
alertStates: [], alertStates: [],
id: 4, id: 4,
index: 0, index: 0,
...@@ -211,6 +213,7 @@ suite('TabList', () => { ...@@ -211,6 +213,7 @@ suite('TabList', () => {
test('adds a pinned tab to its designated container', () => { test('adds a pinned tab to its designated container', () => {
webUIListenerCallback('tab-created', { webUIListenerCallback('tab-created', {
active: false,
alertStates: [], alertStates: [],
index: 0, index: 0,
title: 'New pinned tab', title: 'New pinned tab',
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'chrome://tab-strip/tab.js'; import 'chrome://tab-strip/tab.js';
import {getFavicon} from 'chrome://resources/js/icon.m.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 {TabStripEmbedderProxy} from 'chrome://tab-strip/tab_strip_embedder_proxy.js';
import {TabNetworkState, TabsApiProxy} from 'chrome://tab-strip/tabs_api_proxy.js'; import {TabNetworkState, TabsApiProxy} from 'chrome://tab-strip/tabs_api_proxy.js';
...@@ -17,13 +18,22 @@ suite('Tab', function() { ...@@ -17,13 +18,22 @@ suite('Tab', function() {
let tabElement; let tabElement;
const tab = { const tab = {
active: false,
alertStates: [], alertStates: [],
id: 1001, id: 1001,
networkState: TabNetworkState.NONE, networkState: TabNetworkState.NONE,
title: 'My title', title: 'My title',
}; };
const strings = {
closeTab: 'Close tab',
tabCrashed: '$1 has crashed',
tabNetworkError: '$1 has a network error',
};
setup(() => { setup(() => {
loadTimeData.overrideValues(strings);
document.body.innerHTML = ''; document.body.innerHTML = '';
testTabStripEmbedderProxy = new TestTabStripEmbedderProxy(); testTabStripEmbedderProxy = new TestTabStripEmbedderProxy();
...@@ -65,6 +75,19 @@ suite('Tab', function() { ...@@ -65,6 +75,19 @@ suite('Tab', function() {
assertFalse(tabElement.hasAttribute('active')); 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', () => { test('hides entire favicon container when showIcon is false', () => {
// disable transitions // disable transitions
tabElement.style.setProperty('--tabstrip-tab-transition-duration', '0ms'); tabElement.style.setProperty('--tabstrip-tab-transition-duration', '0ms');
...@@ -158,7 +181,7 @@ suite('Tab', function() { ...@@ -158,7 +181,7 @@ suite('Tab', function() {
}); });
test('clicking on the element activates the tab', () => { test('clicking on the element activates the tab', () => {
tabElement.click(); tabElement.shadowRoot.querySelector('#tab').click();
return testTabsApiProxy.whenCalled('activateTab', tabId => { return testTabsApiProxy.whenCalled('activateTab', tabId => {
assertEquals(tabId, tab.id); assertEquals(tabId, tab.id);
}); });
...@@ -247,15 +270,14 @@ suite('Tab', function() { ...@@ -247,15 +270,14 @@ suite('Tab', function() {
test('getting the drag image grabs the contents', () => { test('getting the drag image grabs the contents', () => {
assertEquals( assertEquals(
tabElement.getDragImage(), tabElement.getDragImage(), tabElement.shadowRoot.querySelector('#tab'));
tabElement.shadowRoot.querySelector('#dragImage'));
}); });
test('has custom context menu', async () => { test('has custom context menu', async () => {
let event = new Event('contextmenu'); let event = new Event('contextmenu');
event.clientX = 1; event.clientX = 1;
event.clientY = 2; event.clientY = 2;
tabElement.dispatchEvent(event); tabElement.shadowRoot.querySelector('#tab').dispatchEvent(event);
const contextMenuArgs = const contextMenuArgs =
await testTabStripEmbedderProxy.whenCalled('showTabContextMenu'); await testTabStripEmbedderProxy.whenCalled('showTabContextMenu');
...@@ -266,7 +288,28 @@ suite('Tab', function() { ...@@ -266,7 +288,28 @@ suite('Tab', function() {
test('activating closes WebUI container', () => { test('activating closes WebUI container', () => {
assertEquals(testTabStripEmbedderProxy.getCallCount('closeContainer'), 0); assertEquals(testTabStripEmbedderProxy.getCallCount('closeContainer'), 0);
tabElement.click(); tabElement.shadowRoot.querySelector('#tab').click();
assertEquals(testTabStripEmbedderProxy.getCallCount('closeContainer'), 1); 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