Files
react-native-devtools-frontend/front_end/resources/ServiceWorkersView.js
T
Rob Paveza cb8321822d DevTools: Service Worker URLs are not read by screen readers
When a screen reader user is tabbing through the registered
service workers, the screen reader fails to announce the origins
from which the service workers are registered. By default, the
reader just announces "Update", as the Update button is the first
focusable element within each individual Service Worker view.

This change adds a new API to ReportView.Section. Because Section
is a generic control which may not always follow this paradigm,
the new API seems to be the best choice for how to implement. The
API is opt-in by call sites as demonstrated by this use of it.
The API sets the role="group" on the containing element, and
applies a label to the containing element. Upon focus entering the
containing element, the full title is read aloud.

"After" screencap: https://imgur.com/a/XfMg2SA

Bug: 963183
Change-Id: I5ef631d24d9f2df9f610a21c84fd5a9d6ae26b48
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1864721
Reviewed-by: Lorne Mitchell <lomitch@microsoft.com>
Commit-Queue: Robert Paveza <Rob.Paveza@microsoft.com>
Cr-Original-Commit-Position: refs/heads/master@{#706688}
Cr-Mirrored-From: https://chromium.googlesource.com/chromium/src
Cr-Mirrored-Commit: e1961cc7bcaddd8cc0fc6cf86bfea5ff1998c989
2019-10-16 23:13:00 +00:00

660 lines
24 KiB
JavaScript

// Copyright (c) 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @implements {SDK.SDKModelObserver<!SDK.ServiceWorkerManager>}
*/
Resources.ServiceWorkersView = class extends UI.VBox {
constructor() {
super(true);
this.registerRequiredCSS('resources/serviceWorkersView.css');
this._currentWorkersView = new UI.ReportView(Common.UIString('Service Workers'));
this._currentWorkersView.setBodyScrollable(false);
this.contentElement.classList.add('service-worker-list');
this._currentWorkersView.show(this.contentElement);
this._currentWorkersView.element.classList.add('service-workers-this-origin');
this._toolbar = this._currentWorkersView.createToolbar();
this._toolbar.makeWrappable(true /* growVertically */);
/** @type {!Map<!SDK.ServiceWorkerRegistration, !Resources.ServiceWorkersView.Section>} */
this._sections = new Map();
/** @type {symbol} */
this._registrationSymbol = Symbol('Resources.ServiceWorkersView');
/** @type {?SDK.ServiceWorkerManager} */
this._manager = null;
/** @type {?SDK.SecurityOriginManager} */
this._securityOriginManager = null;
this._filterThrottler = new Common.Throttler(300);
this._otherWorkers = this.contentElement.createChild('div', 'service-workers-other-origin');
this._otherSWFilter = this._otherWorkers.createChild('div', 'service-worker-filter');
this._otherSWFilter.setAttribute('tabindex', 0);
this._otherSWFilter.setAttribute('role', 'switch');
this._otherSWFilter.setAttribute('aria-checked', false);
const filterLabel = this._otherSWFilter.createChild('label', 'service-worker-filter-label');
filterLabel.textContent = Common.UIString('Service workers from other origins');
self.onInvokeElement(this._otherSWFilter, event => {
if (event.target === this._otherSWFilter || event.target === filterLabel) {
this._toggleFilter();
}
});
const toolbar = new UI.Toolbar('service-worker-filter-toolbar', this._otherSWFilter);
this._filter = new UI.ToolbarInput(ls`Filter service worker`, '', 1);
this._filter.addEventListener(UI.ToolbarInput.Event.TextChanged, () => this._filterChanged());
toolbar.appendToolbarItem(this._filter);
this._otherWorkersView = new UI.ReportView();
this._otherWorkersView.setBodyScrollable(false);
this._otherWorkersView.show(this._otherWorkers);
this._otherWorkersView.element.classList.add('service-workers-for-other-origins');
this._updateCollapsedStyle();
this._toolbar.appendToolbarItem(MobileThrottling.throttlingManager().createOfflineToolbarCheckbox());
const updateOnReloadSetting = Common.settings.createSetting('serviceWorkerUpdateOnReload', false);
updateOnReloadSetting.setTitle(Common.UIString('Update on reload'));
const forceUpdate = new UI.ToolbarSettingCheckbox(
updateOnReloadSetting, ls`On page reload, force the service worker to update, and activate it`);
this._toolbar.appendToolbarItem(forceUpdate);
const bypassServiceWorkerSetting = Common.settings.createSetting('bypassServiceWorker', false);
bypassServiceWorkerSetting.setTitle(Common.UIString('Bypass for network'));
const fallbackToNetwork = new UI.ToolbarSettingCheckbox(
bypassServiceWorkerSetting, ls`Bypass the service worker and load resources from the network`);
this._toolbar.appendToolbarItem(fallbackToNetwork);
/** @type {!Map<!SDK.ServiceWorkerManager, !Array<!Common.EventTarget.EventDescriptor>>}*/
this._eventListeners = new Map();
SDK.targetManager.observeModels(SDK.ServiceWorkerManager, this);
this._updateListVisibility();
}
/**
* @override
* @param {!SDK.ServiceWorkerManager} serviceWorkerManager
*/
modelAdded(serviceWorkerManager) {
if (this._manager) {
return;
}
this._manager = serviceWorkerManager;
this._securityOriginManager = serviceWorkerManager.target().model(SDK.SecurityOriginManager);
for (const registration of this._manager.registrations().values()) {
this._updateRegistration(registration);
}
this._eventListeners.set(serviceWorkerManager, [
this._manager.addEventListener(
SDK.ServiceWorkerManager.Events.RegistrationUpdated, this._registrationUpdated, this),
this._manager.addEventListener(
SDK.ServiceWorkerManager.Events.RegistrationDeleted, this._registrationDeleted, this),
this._securityOriginManager.addEventListener(
SDK.SecurityOriginManager.Events.SecurityOriginAdded, this._updateSectionVisibility, this),
this._securityOriginManager.addEventListener(
SDK.SecurityOriginManager.Events.SecurityOriginRemoved, this._updateSectionVisibility, this),
]);
}
/**
* @override
* @param {!SDK.ServiceWorkerManager} serviceWorkerManager
*/
modelRemoved(serviceWorkerManager) {
if (!this._manager || this._manager !== serviceWorkerManager) {
return;
}
Common.EventTarget.removeEventListeners(this._eventListeners.get(serviceWorkerManager));
this._eventListeners.delete(serviceWorkerManager);
this._manager = null;
this._securityOriginManager = null;
}
/**
* @param {!SDK.ServiceWorkerRegistration} registration
* @return {number}
*/
_getTimeStamp(registration) {
const versions = registration.versionsByMode();
let timestamp = 0;
const active = versions.get(SDK.ServiceWorkerVersion.Modes.Active);
const installing = versions.get(SDK.ServiceWorkerVersion.Modes.Installing);
const waiting = versions.get(SDK.ServiceWorkerVersion.Modes.Waiting);
const redundant = versions.get(SDK.ServiceWorkerVersion.Modes.Redundant);
if (active) {
timestamp = active.scriptResponseTime;
} else if (waiting) {
timestamp = waiting.scriptResponseTime;
} else if (installing) {
timestamp = installing.scriptResponseTime;
} else if (redundant) {
timestamp = redundant.scriptResponseTime;
}
return timestamp;
}
_updateSectionVisibility() {
let hasOthers = false;
let hasThis = false;
const movedSections = [];
for (const section of this._sections.values()) {
const expectedView = this._getReportViewForOrigin(section._registration.securityOrigin);
hasOthers |= expectedView === this._otherWorkersView;
hasThis |= expectedView === this._currentWorkersView;
if (section._section.parentWidget() !== expectedView) {
movedSections.push(section);
}
}
for (const section of movedSections) {
const registration = section._registration;
this._removeRegistrationFromList(registration);
this._updateRegistration(registration, true);
}
this._currentWorkersView.sortSections((a, b) => {
const aTimestamp = this._getTimeStamp(a[this._registrationSymbol]);
const bTimestamp = this._getTimeStamp(b[this._registrationSymbol]);
// the newest (largest timestamp value) should be the first
return bTimestamp - aTimestamp;
});
const scorer = new Sources.FilePathScoreFunction(this._filter.value());
this._otherWorkersView.sortSections((a, b) => {
const cmp = scorer.score(b.title(), null) - scorer.score(a.title(), null);
return cmp === 0 ? a.title().localeCompare(b.title()) : cmp;
});
for (const section of this._sections.values()) {
if (section._section.parentWidget() === this._currentWorkersView ||
this._isRegistrationVisible(section._registration)) {
section._section.showWidget();
} else {
section._section.hideWidget();
}
}
this.contentElement.classList.toggle('service-worker-has-current', !!hasThis);
this._otherWorkers.classList.toggle('hidden', !hasOthers);
this._updateListVisibility();
}
/**
* @param {!Common.Event} event
*/
_registrationUpdated(event) {
const registration = /** @type {!SDK.ServiceWorkerRegistration} */ (event.data);
this._updateRegistration(registration);
this._gcRegistrations();
}
_gcRegistrations() {
let hasNonDeletedRegistrations = false;
const securityOrigins = new Set(this._securityOriginManager.securityOrigins());
for (const registration of this._manager.registrations().values()) {
if (!securityOrigins.has(registration.securityOrigin) && !this._isRegistrationVisible(registration)) {
continue;
}
if (!registration.canBeRemoved()) {
hasNonDeletedRegistrations = true;
break;
}
}
if (!hasNonDeletedRegistrations) {
return;
}
for (const registration of this._manager.registrations().values()) {
const visible = securityOrigins.has(registration.securityOrigin) || this._isRegistrationVisible(registration);
if (!visible && registration.canBeRemoved()) {
this._removeRegistrationFromList(registration);
}
}
}
/**
* @param {string} origin
* @return {!UI.ReportView}
*/
_getReportViewForOrigin(origin) {
if (this._securityOriginManager.securityOrigins().includes(origin) ||
this._securityOriginManager.unreachableMainSecurityOrigin() === origin) {
return this._currentWorkersView;
} else {
return this._otherWorkersView;
}
}
/**
* @param {!SDK.ServiceWorkerRegistration} registration
* @param {boolean=} skipUpdate
*/
_updateRegistration(registration, skipUpdate) {
let section = this._sections.get(registration);
if (!section) {
const title = registration.scopeURL;
const uiSection = this._getReportViewForOrigin(registration.securityOrigin).appendSection(title);
uiSection.setUiGroupTitle(ls`Service worker for ${title}`);
uiSection[this._registrationSymbol] = registration;
section = new Resources.ServiceWorkersView.Section(
/** @type {!SDK.ServiceWorkerManager} */ (this._manager), uiSection, registration);
this._sections.set(registration, section);
}
if (skipUpdate) {
return;
}
this._updateSectionVisibility();
section._scheduleUpdate();
}
/**
* @param {!Common.Event} event
*/
_registrationDeleted(event) {
const registration = /** @type {!SDK.ServiceWorkerRegistration} */ (event.data);
this._removeRegistrationFromList(registration);
}
/**
* @param {!SDK.ServiceWorkerRegistration} registration
*/
_removeRegistrationFromList(registration) {
const section = this._sections.get(registration);
if (section) {
section._section.detach();
}
this._sections.delete(registration);
this._updateSectionVisibility();
}
/**
* @param {!SDK.ServiceWorkerRegistration} registration
* @return {boolean}
*/
_isRegistrationVisible(registration) {
const filterString = this._filter.value();
if (!filterString || !registration.scopeURL) {
return true;
}
const regex = String.filterRegex(filterString);
return registration.scopeURL.match(regex);
}
_filterChanged() {
this._updateCollapsedStyle();
this._filterThrottler.schedule(() => Promise.resolve(this._updateSectionVisibility()));
}
_updateCollapsedStyle() {
const expanded = this._otherSWFilter.getAttribute('aria-checked') === 'true';
this._otherWorkers.classList.toggle('service-worker-filter-collapsed', !expanded);
if (expanded) {
this._otherWorkersView.showWidget();
} else {
this._otherWorkersView.hideWidget();
}
this._otherWorkersView.setHeaderVisible(false);
}
_updateListVisibility() {
this.contentElement.classList.toggle('service-worker-list-empty', this._sections.size === 0);
}
_toggleFilter() {
const expanded = this._otherSWFilter.getAttribute('aria-checked') === 'true';
this._otherSWFilter.setAttribute('aria-checked', !expanded);
this._filterChanged();
}
};
Resources.ServiceWorkersView.Section = class {
/**
* @param {!SDK.ServiceWorkerManager} manager
* @param {!UI.ReportView.Section} section
* @param {!SDK.ServiceWorkerRegistration} registration
*/
constructor(manager, section, registration) {
this._manager = manager;
this._section = section;
this._registration = registration;
/** @type {?symbol} */
this._fingerprint = null;
this._pushNotificationDataSetting =
Common.settings.createLocalSetting('pushData', Common.UIString('Test push message from DevTools.'));
this._syncTagNameSetting = Common.settings.createLocalSetting('syncTagName', 'test-tag-from-devtools');
this._periodicSyncTagNameSetting =
Common.settings.createLocalSetting('periodicSyncTagName', 'test-tag-from-devtools');
this._toolbar = section.createToolbar();
this._toolbar.renderAsLinks();
this._updateButton = new UI.ToolbarButton(Common.UIString('Update'), undefined, Common.UIString('Update'));
this._updateButton.addEventListener(UI.ToolbarButton.Events.Click, this._updateButtonClicked, this);
this._toolbar.appendToolbarItem(this._updateButton);
this._deleteButton =
new UI.ToolbarButton(Common.UIString('Unregister service worker'), undefined, Common.UIString('Unregister'));
this._deleteButton.addEventListener(UI.ToolbarButton.Events.Click, this._unregisterButtonClicked, this);
this._toolbar.appendToolbarItem(this._deleteButton);
// Preserve the order.
this._sourceField = this._wrapWidget(this._section.appendField(Common.UIString('Source')));
this._statusField = this._wrapWidget(this._section.appendField(Common.UIString('Status')));
this._clientsField = this._wrapWidget(this._section.appendField(Common.UIString('Clients')));
this._createSyncNotificationField(
Common.UIString('Push'), this._pushNotificationDataSetting.get(), Common.UIString('Push data'),
this._push.bind(this));
this._createSyncNotificationField(
Common.UIString('Sync'), this._syncTagNameSetting.get(), Common.UIString('Sync tag'), this._sync.bind(this));
if (Root.Runtime.experiments.isEnabled('backgroundServicesPeriodicBackgroundSync')) {
this._createSyncNotificationField(
ls`Periodic Sync`, this._periodicSyncTagNameSetting.get(), ls`Periodic Sync tag`,
tag => this._periodicSync(tag));
}
this._linkifier = new Components.Linkifier();
/** @type {!Map<string, !Protocol.Target.TargetInfo>} */
this._clientInfoCache = new Map();
this._throttler = new Common.Throttler(500);
}
/**
* @param {string} label
* @param {string} initialValue
* @param {string} placeholder
* @param {function(string)} callback
*/
_createSyncNotificationField(label, initialValue, placeholder, callback) {
const form =
this._wrapWidget(this._section.appendField(label)).createChild('form', 'service-worker-editor-with-button');
const editor = form.createChild('input', 'source-code service-worker-notification-editor');
const button = UI.createTextButton(label);
button.type = 'submit';
form.appendChild(button);
editor.value = initialValue;
editor.placeholder = placeholder;
UI.ARIAUtils.setAccessibleName(editor, label);
form.addEventListener('submit', e => {
callback(editor.value || '');
e.consume(true);
});
}
_scheduleUpdate() {
if (Resources.ServiceWorkersView._noThrottle) {
this._update();
return;
}
this._throttler.schedule(this._update.bind(this));
}
/**
* @param {string} versionId
* @return {?SDK.Target}
*/
_targetForVersionId(versionId) {
const version = this._manager.findVersion(versionId);
if (!version || !version.targetId) {
return null;
}
return SDK.targetManager.targetById(version.targetId);
}
/**
* @param {!Element} versionsStack
* @param {string} icon
* @param {string} label
* @return {!Element}
*/
_addVersion(versionsStack, icon, label) {
const installingEntry = versionsStack.createChild('div', 'service-worker-version');
installingEntry.createChild('div', icon);
installingEntry.createChild('span').textContent = label;
return installingEntry;
}
/**
* @param {!SDK.ServiceWorkerVersion} version
*/
_updateClientsField(version) {
this._clientsField.removeChildren();
this._section.setFieldVisible(Common.UIString('Clients'), version.controlledClients.length);
for (const client of version.controlledClients) {
const clientLabelText = this._clientsField.createChild('div', 'service-worker-client');
if (this._clientInfoCache.has(client)) {
this._updateClientInfo(
clientLabelText, /** @type {!Protocol.Target.TargetInfo} */ (this._clientInfoCache.get(client)));
}
this._manager.target().targetAgent().getTargetInfo(client).then(this._onClientInfo.bind(this, clientLabelText));
}
}
/**
* @param {!SDK.ServiceWorkerVersion} version
*/
_updateSourceField(version) {
this._sourceField.removeChildren();
const fileName = Common.ParsedURL.extractName(version.scriptURL);
const name = this._sourceField.createChild('div', 'report-field-value-filename');
const link = Components.Linkifier.linkifyURL(version.scriptURL, {text: fileName});
link.tabIndex = 0;
name.appendChild(link);
if (this._registration.errors.length) {
const errorsLabel = UI.createIconLabel(String(this._registration.errors.length), 'smallicon-error');
errorsLabel.classList.add('link');
errorsLabel.tabIndex = 0;
UI.ARIAUtils.setAccessibleName(errorsLabel, ls`${this._registration.errors.length} registration errors`);
self.onInvokeElement(errorsLabel, () => Common.console.show());
name.appendChild(errorsLabel);
}
this._sourceField.createChild('div', 'report-field-value-subtitle').textContent =
Common.UIString('Received %s', new Date(version.scriptResponseTime * 1000).toLocaleString());
}
/**
* @return {!Promise}
*/
_update() {
const fingerprint = this._registration.fingerprint();
if (fingerprint === this._fingerprint) {
return Promise.resolve();
}
this._fingerprint = fingerprint;
this._toolbar.setEnabled(!this._registration.isDeleted);
const versions = this._registration.versionsByMode();
const scopeURL = this._registration.scopeURL;
const title = this._registration.isDeleted ? Common.UIString('%s - deleted', scopeURL) : scopeURL;
this._section.setTitle(title);
const active = versions.get(SDK.ServiceWorkerVersion.Modes.Active);
const waiting = versions.get(SDK.ServiceWorkerVersion.Modes.Waiting);
const installing = versions.get(SDK.ServiceWorkerVersion.Modes.Installing);
const redundant = versions.get(SDK.ServiceWorkerVersion.Modes.Redundant);
this._statusField.removeChildren();
const versionsStack = this._statusField.createChild('div', 'service-worker-version-stack');
versionsStack.createChild('div', 'service-worker-version-stack-bar');
if (active) {
this._updateSourceField(active);
const localizedRunningStatus = SDK.ServiceWorkerVersion.RunningStatus[active.runningStatus];
const activeEntry = this._addVersion(
versionsStack, 'service-worker-active-circle', ls`#${active.id} activated and is ${localizedRunningStatus}`);
if (active.isRunning() || active.isStarting()) {
this._createLink(activeEntry, Common.UIString('stop'), this._stopButtonClicked.bind(this, active.id));
if (!this._targetForVersionId(active.id)) {
this._createLink(activeEntry, Common.UIString('inspect'), this._inspectButtonClicked.bind(this, active.id));
}
} else if (active.isStartable()) {
this._createLink(activeEntry, Common.UIString('start'), this._startButtonClicked.bind(this));
}
this._updateClientsField(active);
} else if (redundant) {
this._updateSourceField(redundant);
this._addVersion(
versionsStack, 'service-worker-redundant-circle', Common.UIString('#%s is redundant', redundant.id));
this._updateClientsField(redundant);
}
if (waiting) {
const waitingEntry = this._addVersion(
versionsStack, 'service-worker-waiting-circle', Common.UIString('#%s waiting to activate', waiting.id));
this._createLink(waitingEntry, Common.UIString('skipWaiting'), this._skipButtonClicked.bind(this));
waitingEntry.createChild('div', 'service-worker-subtitle').textContent =
Common.UIString('Received %s', new Date(waiting.scriptResponseTime * 1000).toLocaleString());
if (!this._targetForVersionId(waiting.id) && (waiting.isRunning() || waiting.isStarting())) {
this._createLink(waitingEntry, Common.UIString('inspect'), this._inspectButtonClicked.bind(this, waiting.id));
}
}
if (installing) {
const installingEntry = this._addVersion(
versionsStack, 'service-worker-installing-circle', Common.UIString('#%s trying to install', installing.id));
installingEntry.createChild('div', 'service-worker-subtitle').textContent =
Common.UIString('Received %s', new Date(installing.scriptResponseTime * 1000).toLocaleString());
if (!this._targetForVersionId(installing.id) && (installing.isRunning() || installing.isStarting())) {
this._createLink(
installingEntry, Common.UIString('inspect'), this._inspectButtonClicked.bind(this, installing.id));
}
}
return Promise.resolve();
}
/**
* @param {!Element} parent
* @param {string} title
* @param {function()} listener
* @param {string=} className
* @param {boolean=} useCapture
* @return {!Element}
*/
_createLink(parent, title, listener, className, useCapture) {
const button = parent.createChild('button', className);
button.classList.add('link');
button.textContent = title;
button.tabIndex = 0;
button.addEventListener('click', listener, useCapture);
return button;
}
/**
* @param {!Common.Event} event
*/
_unregisterButtonClicked(event) {
this._manager.deleteRegistration(this._registration.id);
}
/**
* @param {!Common.Event} event
*/
_updateButtonClicked(event) {
this._manager.updateRegistration(this._registration.id);
}
/**
* @param {string} data
*/
_push(data) {
this._pushNotificationDataSetting.set(data);
this._manager.deliverPushMessage(this._registration.id, data);
}
/**
* @param {string} tag
*/
_sync(tag) {
this._syncTagNameSetting.set(tag);
this._manager.dispatchSyncEvent(this._registration.id, tag, true);
}
/**
* @param {string} tag
*/
_periodicSync(tag) {
this._periodicSyncTagNameSetting.set(tag);
this._manager.dispatchPeriodicSyncEvent(this._registration.id, tag);
}
/**
* @param {!Element} element
* @param {?Protocol.Target.TargetInfo} targetInfo
*/
_onClientInfo(element, targetInfo) {
if (!targetInfo) {
return;
}
this._clientInfoCache.set(targetInfo.targetId, targetInfo);
this._updateClientInfo(element, targetInfo);
}
/**
* @param {!Element} element
* @param {!Protocol.Target.TargetInfo} targetInfo
*/
_updateClientInfo(element, targetInfo) {
if (targetInfo.type !== 'page' && targetInfo.type === 'iframe') {
const clientString = element.createChild('span', 'service-worker-client-string');
clientString.createTextChild(ls`Worker: ${targetInfo.url}`);
return;
}
element.removeChildren();
const clientString = element.createChild('span', 'service-worker-client-string');
clientString.createTextChild(targetInfo.url);
this._createLink(
element, ls`focus`, this._activateTarget.bind(this, targetInfo.targetId), 'service-worker-client-focus-link');
}
/**
* @param {string} targetId
*/
_activateTarget(targetId) {
this._manager.target().targetAgent().activateTarget(targetId);
}
_startButtonClicked() {
this._manager.startWorker(this._registration.scopeURL);
}
_skipButtonClicked() {
this._manager.skipWaiting(this._registration.scopeURL);
}
/**
* @param {string} versionId
*/
_stopButtonClicked(versionId) {
this._manager.stopWorker(versionId);
}
/**
* @param {string} versionId
*/
_inspectButtonClicked(versionId) {
this._manager.inspectWorker(versionId);
}
/**
* @param {!Element} container
* @return {!Element}
*/
_wrapWidget(container) {
const shadowRoot = UI.createShadowRootWithCoreStyles(container);
UI.appendStyle(shadowRoot, 'resources/serviceWorkersView.css');
const contentElement = createElement('div');
shadowRoot.appendChild(contentElement);
return contentElement;
}
};