Files
tubestation/browser/components/firefoxview/syncedtabs.mjs
Anna Yeddi 5b057d0148 Bug 1872174 - Add programmatic role to Show All controls in Firefox View. r=fxview-reviewers,kcochrane
Several `show-all-link` controls lack interactive role - this would prevent users of speech-to-text/Voice Control from being able to send a click to it by calling its label and screen reader users and users of other assistive technology would not be able to get to this control via shortcuts like a list of controls, etc. Being focusable with keyboard does not guarantee that assistive technology could guess that this programmatically static text is, in fact, interactive.

While providing an explicit ARIA `role` to the element is going [against the First Rule of ARIA](https://www.w3.org/TR/using-aria/#rule1) while semantically appropriate HTML elements exist - `<a>` anchor (for the visual appearance of this control) or `<button>` (for the actual functionality that this control performs) - this patch would at least remove the barrier for assistive technology to be able to set interactivity expectations to this control appropriately.

No test manifests are being updated, because there are other elements in the updated test files that are failing a11y-checks, besides these `show-all-link` controls.

Differential Revision: https://phabricator.services.mozilla.com/D197349
2023-12-28 14:37:24 +00:00

693 lines
20 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
});
const { SyncedTabsErrorHandler } = ChromeUtils.importESModule(
"resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs"
);
const { TabsSetupFlowManager } = ChromeUtils.importESModule(
"resource:///modules/firefox-view-tabs-setup-manager.sys.mjs"
);
import {
html,
ifDefined,
when,
} from "chrome://global/content/vendor/lit.all.mjs";
import { ViewPage } from "./viewpage.mjs";
import {
escapeHtmlEntities,
isSearchEnabled,
searchTabList,
MAX_TABS_FOR_RECENT_BROWSING,
} from "./helpers.mjs";
const SYNCED_TABS_CHANGED = "services.sync.tabs.changed";
const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open";
class SyncedTabsInView extends ViewPage {
constructor() {
super();
this._started = false;
this.boundObserve = (...args) => this.observe(...args);
this._currentSetupStateIndex = -1;
this.errorState = null;
this._id = Math.floor(Math.random() * 10e6);
this.currentSyncedTabs = [];
if (this.recentBrowsing) {
this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING;
} else {
// Setting maxTabsLength to -1 for no max
this.maxTabsLength = -1;
}
this.devices = [];
this.fullyUpdated = false;
this.searchQuery = "";
this.showAll = false;
}
static properties = {
...ViewPage.properties,
errorState: { type: Number },
currentSyncedTabs: { type: Array },
_currentSetupStateIndex: { type: Number },
devices: { type: Array },
searchQuery: { type: String },
showAll: { type: Boolean },
};
static queries = {
cardEls: { all: "card-container" },
emptyState: "fxview-empty-state",
searchTextbox: "fxview-search-textbox",
tabLists: { all: "fxview-tab-list" },
};
connectedCallback() {
super.connectedCallback();
this.addEventListener("click", this);
}
start() {
if (this._started) {
return;
}
this._started = true;
Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED);
Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED);
this.updateStates();
this.onVisibilityChange();
if (this.recentBrowsing) {
this.recentBrowsingElement.addEventListener(
"fxview-search-textbox-query",
this
);
}
}
stop() {
if (!this._started) {
return;
}
this._started = false;
TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded");
this.onVisibilityChange();
Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED);
Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED);
if (this.recentBrowsing) {
this.recentBrowsingElement.removeEventListener(
"fxview-search-textbox-query",
this
);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stop();
}
handleEvent(event) {
if (event.type == "click" && event.target.dataset.action) {
const { ErrorType } = SyncedTabsErrorHandler;
switch (event.target.dataset.action) {
case `${ErrorType.SYNC_ERROR}`:
case `${ErrorType.NETWORK_OFFLINE}`:
case `${ErrorType.PASSWORD_LOCKED}`: {
TabsSetupFlowManager.tryToClearError();
break;
}
case `${ErrorType.SIGNED_OUT}`:
case "sign-in": {
TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal);
break;
}
case "add-device": {
TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal);
break;
}
case "sync-tabs-disabled": {
TabsSetupFlowManager.syncOpenTabs(event.target);
break;
}
case `${ErrorType.SYNC_DISCONNECTED}`: {
const win = event.target.ownerGlobal;
const { switchToTabHavingURI } =
win.docShell.chromeEventHandler.ownerGlobal;
switchToTabHavingURI(
"about:preferences?action=choose-what-to-sync#sync",
true,
{}
);
break;
}
}
}
if (event.type == "change") {
TabsSetupFlowManager.syncOpenTabs(event.target);
}
if (this.recentBrowsing && event.type === "fxview-search-textbox-query") {
this.onSearchQuery(event);
}
}
viewVisibleCallback() {
this.start();
}
viewHiddenCallback() {
this.stop();
}
onVisibilityChange() {
const isOpen = this.open;
const isVisible = this.isVisible;
if (isVisible && isOpen) {
this.update();
TabsSetupFlowManager.updateViewVisibility(this._id, "visible");
} else {
TabsSetupFlowManager.updateViewVisibility(
this._id,
isVisible ? "closed" : "hidden"
);
}
this.toggleVisibilityInCardContainer();
}
async observe(subject, topic, errorState) {
if (topic == TOPIC_SETUPSTATE_CHANGED) {
this.updateStates(errorState);
}
if (topic == SYNCED_TABS_CHANGED) {
this.getSyncedTabData();
}
}
updateStates(errorState) {
let stateIndex = TabsSetupFlowManager.uiStateIndex;
errorState = errorState || SyncedTabsErrorHandler.getErrorType();
if (stateIndex == 4 && this._currentSetupStateIndex !== stateIndex) {
// trigger an initial request for the synced tabs list
this.getSyncedTabData();
}
this._currentSetupStateIndex = stateIndex;
this.errorState = errorState;
}
actionMappings = {
"sign-in": {
header: "firefoxview-syncedtabs-signin-header",
description: "firefoxview-syncedtabs-signin-description",
buttonLabel: "firefoxview-syncedtabs-signin-primarybutton",
},
"add-device": {
header: "firefoxview-syncedtabs-adddevice-header",
description: "firefoxview-syncedtabs-adddevice-description",
buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton",
descriptionLink: {
name: "url",
url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync",
},
},
"sync-tabs-disabled": {
header: "firefoxview-syncedtabs-synctabs-header",
description: "firefoxview-syncedtabs-synctabs-description",
buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton",
},
loading: {
header: "firefoxview-syncedtabs-loading-header",
description: "firefoxview-syncedtabs-loading-description",
},
};
generateMessageCard({ error = false, action, errorState }) {
errorState = errorState || this.errorState;
let header,
description,
descriptionLink,
buttonLabel,
headerIconUrl,
mainImageUrl;
let descriptionArray;
if (error) {
let link;
({ header, description, link, buttonLabel } =
SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState));
action = `${errorState}`;
headerIconUrl = "chrome://global/skin/icons/info-filled.svg";
mainImageUrl =
"chrome://browser/content/firefoxview/synced-tabs-error.svg";
descriptionArray = [description];
if (errorState == "password-locked") {
descriptionLink = {};
// This is ugly, but we need to special case this link so we can
// coexist with the old view.
descriptionArray.push("firefoxview-syncedtab-password-locked-link");
descriptionLink.name = "syncedtab-password-locked-link";
descriptionLink.url = link.href;
}
} else {
header = this.actionMappings[action].header;
description = this.actionMappings[action].description;
buttonLabel = this.actionMappings[action].buttonLabel;
descriptionLink = this.actionMappings[action].descriptionLink;
mainImageUrl =
"chrome://browser/content/firefoxview/synced-tabs-error.svg";
descriptionArray = [description];
}
return html`
<fxview-empty-state
headerLabel=${header}
.descriptionLabels=${descriptionArray}
.descriptionLink=${ifDefined(descriptionLink)}
class="empty-state synced-tabs error"
?isSelectedTab=${this.selectedTab}
?isInnerCard=${this.recentBrowsing}
mainImageUrl="${ifDefined(mainImageUrl)}"
?errorGrayscale=${error}
headerIconUrl="${ifDefined(headerIconUrl)}"
id="empty-container"
>
<button
class="primary"
slot="primary-action"
?hidden=${!buttonLabel}
data-l10n-id="${ifDefined(buttonLabel)}"
data-action="${action}"
@click=${this.handleEvent}
aria-details="empty-container"
></button>
</fxview-empty-state>
`;
}
onOpenLink(event) {
let currentWindow = this.getWindow();
if (currentWindow.openTrustedLinkIn) {
let where = lazy.BrowserUtils.whereToOpenLink(
event.detail.originalEvent,
false,
true
);
if (where == "current") {
where = "tab";
}
currentWindow.openTrustedLinkIn(event.originalTarget.url, where);
Services.telemetry.recordEvent(
"firefoxview_next",
"synced_tabs",
"tabs",
null,
{
page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs",
}
);
}
}
onContextMenu(e) {
this.triggerNode = e.originalTarget;
e.target.querySelector("panel-list").toggle(e.detail.originalEvent);
}
panelListTemplate() {
return html`
<panel-list slot="menu" data-tab-type="syncedtabs">
<panel-item
@click=${this.openInNewWindow}
data-l10n-id="fxviewtabrow-open-in-window"
data-l10n-attrs="accesskey"
></panel-item>
<panel-item
@click=${this.openInNewPrivateWindow}
data-l10n-id="fxviewtabrow-open-in-private-window"
data-l10n-attrs="accesskey"
></panel-item>
<hr />
<panel-item
@click=${this.copyLink}
data-l10n-id="fxviewtabrow-copy-link"
data-l10n-attrs="accesskey"
></panel-item>
</panel-list>
`;
}
noDeviceTabsTemplate(deviceName, deviceType, isSearchResultsEmpty = false) {
const template = html`<h3
slot=${ifDefined(this.recentBrowsing ? null : "header")}
class="device-header"
>
<span class="icon ${deviceType}" role="presentation"></span>
${deviceName}
</h3>
${when(
isSearchResultsEmpty,
() => html`
<div
slot=${ifDefined(this.recentBrowsing ? null : "main")}
class="blackbox notabs search-results-empty"
data-l10n-id="firefoxview-search-results-empty"
data-l10n-args=${JSON.stringify({
query: escapeHtmlEntities(this.searchQuery),
})}
></div>
`,
() => html`
<div
slot=${ifDefined(this.recentBrowsing ? null : "main")}
class="blackbox notabs"
data-l10n-id="firefoxview-syncedtabs-device-notabs"
></div>
`
)}`;
return this.recentBrowsing
? template
: html`<card-container
shortPageName=${this.recentBrowsing ? "syncedtabs" : null}
>${template}</card-container
>`;
}
onSearchQuery(e) {
this.searchQuery = e.detail.query;
this.showAll = false;
}
deviceTemplate(deviceName, deviceType, tabItems) {
return html`<h3
slot=${!this.recentBrowsing ? "header" : null}
class="device-header"
>
<span class="icon ${deviceType}" role="presentation"></span>
${deviceName}
</h3>
<fxview-tab-list
slot="main"
class="with-context-menu"
hasPopup="menu"
.tabItems=${ifDefined(tabItems)}
.searchQuery=${this.searchQuery}
maxTabsLength=${this.showAll ? -1 : this.maxTabsLength}
@fxview-tab-list-primary-action=${this.onOpenLink}
@fxview-tab-list-secondary-action=${this.onContextMenu}
>
${this.panelListTemplate()}
</fxview-tab-list>`;
}
generateTabList() {
let renderArray = [];
let renderInfo = {};
for (let tab of this.currentSyncedTabs) {
if (!(tab.client in renderInfo)) {
renderInfo[tab.client] = {
name: tab.device,
deviceType: tab.deviceType,
tabs: [],
};
}
renderInfo[tab.client].tabs.push(tab);
}
// Add devices without tabs
for (let device of this.devices) {
if (!(device.id in renderInfo)) {
renderInfo[device.id] = {
name: device.name,
deviceType: device.clientType,
tabs: [],
};
}
}
for (let id in renderInfo) {
let tabItems = this.searchQuery
? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id].tabs))
: this.getTabItems(renderInfo[id].tabs);
if (tabItems.length) {
const template = this.recentBrowsing
? this.deviceTemplate(
renderInfo[id].name,
renderInfo[id].deviceType,
tabItems
)
: html`<card-container
shortPageName=${this.recentBrowsing ? "syncedtabs" : null}
>${this.deviceTemplate(
renderInfo[id].name,
renderInfo[id].deviceType,
tabItems
)}
</card-container>`;
renderArray.push(template);
if (this.isShowAllLinkVisible(tabItems)) {
renderArray.push(html` <div class="show-all-link-container">
<div
class="show-all-link"
@click=${this.enableShowAll}
@keydown=${this.enableShowAll}
data-l10n-id="firefoxview-show-all"
tabindex="0"
role="link"
></div>
</div>`);
}
} else {
// Check renderInfo[id].tabs.length to determine whether to display an
// empty tab list message or empty search results message.
// If there are no synced tabs, we always display the empty tab list
// message, even if there is an active search query.
renderArray.push(
this.noDeviceTabsTemplate(
renderInfo[id].name,
renderInfo[id].deviceType,
Boolean(renderInfo[id].tabs.length)
)
);
}
}
return renderArray;
}
isShowAllLinkVisible(tabItems) {
return (
this.recentBrowsing &&
this.searchQuery &&
tabItems.length > this.maxTabsLength &&
!this.showAll
);
}
enableShowAll(event) {
if (
event.type == "click" ||
(event.type == "keydown" && event.code == "Enter") ||
(event.type == "keydown" && event.code == "Space")
) {
event.preventDefault();
this.showAll = true;
}
}
generateCardContent() {
switch (this._currentSetupStateIndex) {
case 0 /* error-state */:
if (this.errorState) {
return this.generateMessageCard({ error: true });
}
return this.generateMessageCard({ action: "loading" });
case 1 /* not-signed-in */:
if (Services.prefs.prefHasUserValue("services.sync.lastversion")) {
// If this pref is set, the user has signed out of sync.
// This path is also taken if we are disconnected from sync. See bug 1784055
return this.generateMessageCard({
error: true,
errorState: "signed-out",
});
}
return this.generateMessageCard({ action: "sign-in" });
case 2 /* connect-secondary-device*/:
return this.generateMessageCard({ action: "add-device" });
case 3 /* disabled-tab-sync */:
return this.generateMessageCard({ action: "sync-tabs-disabled" });
case 4 /* synced-tabs-loaded*/:
// There seems to be an edge case where sync says everything worked
// fine but we have no devices.
if (!this.devices.length) {
return this.generateMessageCard({ action: "add-device" });
}
return this.generateTabList();
}
return html``;
}
render() {
this.open =
!TabsSetupFlowManager.isTabSyncSetupComplete ||
Services.prefs.getBoolPref(UI_OPEN_STATE, true);
let renderArray = [];
renderArray.push(html` <link
rel="stylesheet"
href="chrome://browser/content/firefoxview/view-syncedtabs.css"
/>`);
renderArray.push(html` <link
rel="stylesheet"
href="chrome://browser/content/firefoxview/firefoxview-next.css"
/>`);
if (!this.recentBrowsing) {
renderArray.push(html`<div class="sticky-container bottom-fade">
<h2
class="page-header heading-large"
data-l10n-id="firefoxview-synced-tabs-header"
></h2>
${when(
isSearchEnabled() || this._currentSetupStateIndex === 4,
() => html`<div class="syncedtabs-header">
${when(
isSearchEnabled(),
() => html`<div>
<fxview-search-textbox
data-l10n-id="firefoxview-search-text-box-syncedtabs"
data-l10n-attrs="placeholder"
@fxview-search-textbox-query=${this.onSearchQuery}
></fxview-search-textbox>
</div>`
)}
${when(
this._currentSetupStateIndex === 4,
() => html`
<button
class="small-button"
data-action="add-device"
@click=${this.handleEvent}
>
<img
class="icon"
role="presentation"
src="chrome://global/skin/icons/plus.svg"
alt="plus sign"
/><span
data-l10n-id="firefoxview-syncedtabs-connect-another-device"
data-action="add-device"
></span>
</button>
`
)}
</div>`
)}
</div>`);
}
if (this.recentBrowsing) {
renderArray.push(
html`<card-container
preserveCollapseState
shortPageName="syncedtabs"
?showViewAll=${this._currentSetupStateIndex == 4 &&
this.currentSyncedTabs.length}
?isEmptyState=${!this.currentSyncedTabs.length}
>
>
<h3
slot="header"
data-l10n-id="firefoxview-synced-tabs-header"
class="recentbrowsing-header"
></h3>
<div slot="main">${this.generateCardContent()}</div>
</card-container>`
);
} else {
renderArray.push(
html`<div class="cards-container">${this.generateCardContent()}</div>`
);
}
return renderArray;
}
async onReload() {
await TabsSetupFlowManager.syncOnPageReload();
}
getTabItems(tabs) {
tabs = tabs || this.tabs;
return tabs?.map(tab => ({
icon: tab.icon,
title: tab.title,
time: tab.lastUsed * 1000,
url: tab.url,
primaryL10nId: "firefoxview-tabs-list-tab-button",
primaryL10nArgs: JSON.stringify({ targetURI: tab.url }),
secondaryL10nId: "fxviewtabrow-options-menu-button",
secondaryL10nArgs: JSON.stringify({ tabTitle: tab.title }),
}));
}
updateTabsList(syncedTabs) {
if (!syncedTabs.length) {
this.currentSyncedTabs = syncedTabs;
this.sendTabTelemetry(0);
}
const tabsToRender = syncedTabs;
// Return early if new tabs are the same as previous ones
if (
JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs)
) {
return;
}
this.currentSyncedTabs = tabsToRender;
// Record the full tab count
this.sendTabTelemetry(syncedTabs.length);
}
async getSyncedTabData() {
this.devices = await lazy.SyncedTabs.getTabClients();
let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 50, {
removeAllDupes: false,
removeDeviceDupes: true,
});
this.updateTabsList(tabs);
}
updated() {
this.fullyUpdated = true;
this.toggleVisibilityInCardContainer();
}
sendTabTelemetry(numTabs) {
/*
Services.telemetry.recordEvent(
"firefoxview-next",
"synced_tabs",
"tabs",
null,
{
count: numTabs.toString(),
}
);
*/
}
}
customElements.define("view-syncedtabs", SyncedTabsInView);