Files
tubestation/browser/components/firefoxview/featureCallout.mjs
Dan Mosedale 39c12bfdc2 Bug 1790651 - place focus better on feature callout close, r=ayeddi,emcminn
When the user moves focus outside of a callout popup while it's open, this patch tracks and saves the most recently focused element until the focus moves back to the popup. Once the user exits the callout series, the focus is (visibly) placed back where the user last left it.

Differential Revision: https://phabricator.services.mozilla.com/D157398
2022-09-15 19:11:19 +00:00

500 lines
16 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/. */
"use strict";
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
XPCOMUtils.defineLazyModuleGetters(lazy, {
AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
});
// When expanding the use of Feature Callout
// to new about: pages, make `progressPref` a
// configurable field on callout messages and
// use it to determine which pref to observe
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"featureTourProgress",
"browser.firefox-view.feature-tour",
'{"message":"","screen":"","complete":true}',
_handlePrefChange,
val => JSON.parse(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"cfrFeaturesUserPref",
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
true,
_handlePrefChange
);
/* Work around the pref callback being run after the document has been unlinked.
See bug 1543537. */
const docWeak = Cu.getWeakReference(document);
async function _handlePrefChange() {
const doc = docWeak.get();
if (!doc || doc.visibilityState === "hidden") {
return;
}
let prefVal = lazy.featureTourProgress;
// End the tour according to the tour progress pref or if the user disabled
// contextual feature recommendations.
if (prefVal.complete || !lazy.cfrFeaturesUserPref) {
_endTour();
CURRENT_SCREEN = null;
} else if (prefVal.screen !== CURRENT_SCREEN?.id) {
READY = false;
const container = doc.getElementById(CONTAINER_ID);
container?.classList.add("hidden");
// wait for fade out transition
setTimeout(async () => {
await _loadConfig();
container?.remove();
await _renderCallout();
}, TRANSITION_MS);
}
}
function _addCalloutLinkElements() {
function addStylesheet(href) {
const link = document.head.appendChild(document.createElement("link"));
link.rel = "stylesheet";
link.href = href;
}
function addLocalization(hrefs) {
hrefs.forEach(href => {
// eslint-disable-next-line no-undef
MozXULElement.insertFTLIfNeeded(href);
});
}
// Update styling to be compatible with about:welcome bundle
addStylesheet(
"chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
);
addLocalization([
"browser/newtab/onboarding.ftl",
"browser/spotlight.ftl",
"branding/brand.ftl",
"browser/branding/brandings.ftl",
"browser/newtab/asrouter.ftl",
"browser/featureCallout.ftl",
]);
}
let CURRENT_SCREEN;
let CONFIG;
let RENDER_OBSERVER;
let READY = false;
let SAVED_ACTIVE_ELEMENT;
const TRANSITION_MS = 500;
const CONTAINER_ID = "root";
function _createContainer() {
let parent = document.querySelector(CURRENT_SCREEN?.parent_selector);
// Don't render the callout if the parent element is not present.
// This means the message was misconfigured, mistargeted, or the
// content of the parent page is not as expected.
if (!parent) {
return false;
}
let container = document.createElement("div");
container.classList.add(
"onboardingContainer",
"featureCallout",
"callout-arrow",
"hidden"
);
container.id = CONTAINER_ID;
container.setAttribute("aria-describedby", `#${CONTAINER_ID} .welcome-text`);
container.tabIndex = 0;
parent.setAttribute("aria-owns", `${CONTAINER_ID}`);
document.body.appendChild(container);
return container;
}
/**
* Set callout's position relative to parent element
*/
function _positionCallout() {
const container = document.getElementById(CONTAINER_ID);
const parentEl = document.querySelector(CURRENT_SCREEN?.parent_selector);
// All possible arrow positions
const arrowPositions = ["top", "bottom", "end", "start"];
const arrowPosition = CURRENT_SCREEN?.content?.arrow_position || "top";
// Callout should overlap the parent element by 17px (so the box, not
// including the arrow, will overlap by 5px)
const arrowWidth = 12;
let overlap = 17;
// If we have no overlap, we send the callout the same number of pixels
// in the opposite direction
overlap = CURRENT_SCREEN?.content?.noCalloutOverlap ? overlap * -1 : overlap;
overlap -= arrowWidth;
// Is the document layout right to left?
const RTL = document.dir === "rtl";
if (!container || !parentEl) {
return;
}
function getOffset(el) {
const rect = el.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
right: rect.right + window.scrollX,
top: rect.top + window.scrollY,
bottom: rect.bottom + window.scrollY,
};
}
function clearPosition() {
Object.keys(positioners).forEach(position => {
container.style[position] = "unset";
});
arrowPositions.forEach(position => {
if (container.classList.contains(`arrow-${position}`)) {
container.classList.remove(`arrow-${position}`);
}
if (container.classList.contains(`arrow-inline-${position}`)) {
container.classList.remove(`arrow-inline-${position}`);
}
});
}
const positioners = {
// availableSpace should be the space between the edge of the page in the assumed direction
// and the edge of the parent (with the callout being intended to fit between those two edges)
// while needed space should be the space necessary to fit the callout container
top: {
availableSpace:
document.body.offsetHeight -
getOffset(parentEl).top -
parentEl.offsetHeight +
overlap,
neededSpace: container.offsetHeight - overlap,
position() {
// Point to an element above the callout
let containerTop =
getOffset(parentEl).top + parentEl.offsetHeight - overlap;
container.style.top = `${Math.max(
container.offsetHeight - overlap,
containerTop
)}px`;
centerHorizontally(container, parentEl);
container.classList.add("arrow-top");
},
},
bottom: {
availableSpace: getOffset(parentEl).top + overlap,
neededSpace: container.offsetHeight - overlap,
position() {
// Point to an element below the callout
let containerTop =
getOffset(parentEl).top - container.offsetHeight + overlap;
container.style.top = `${Math.max(0, containerTop)}px`;
centerHorizontally(container, parentEl);
container.classList.add("arrow-bottom");
},
},
right: {
availableSpace: getOffset(parentEl).left + overlap,
neededSpace: container.offsetWidth - overlap,
position() {
// Point to an element to the right of the callout
let containerLeft =
getOffset(parentEl).left - container.offsetWidth + overlap;
if (RTL) {
// Account for cases where the document body may be narrow than the window
containerLeft -= window.innerWidth - document.body.offsetWidth;
}
container.style.left = `${Math.max(0, containerLeft)}px`;
container.style.top = `${getOffset(parentEl).top}px`;
container.classList.add("arrow-inline-end");
},
},
left: {
availableSpace:
document.body.offsetWidth - getOffset(parentEl).right + overlap,
neededSpace: container.offsetWidth - overlap,
position() {
// Point to an element to the left of the callout
let containerLeft =
getOffset(parentEl).left + parentEl.offsetWidth - overlap;
if (RTL) {
// Account for cases where the document body may be narrow than the window
containerLeft -= window.innerWidth - document.body.offsetWidth;
}
container.style.left = `${(container.offsetWidth - overlap,
containerLeft)}px`;
container.style.top = `${getOffset(parentEl).top}px`;
container.classList.add("arrow-inline-start");
},
},
};
function calloutFits(position) {
// Does callout element fit in this position relative
// to the parent element without going off screen?
return (
positioners[position].availableSpace > positioners[position].neededSpace
);
}
function choosePosition() {
let position = arrowPosition;
if (!arrowPositions.includes(position)) {
// Configured arrow position is not valid
return false;
}
if (["start", "end"].includes(position)) {
// position here is referencing the direction that the callout container
// is pointing to, and therefore should be the _opposite_ side of the arrow
// eg. if arrow is at the "end" in LTR layouts, the container is pointing
// at an element to the right of itself, while in RTL layouts it is pointing to the left of itself
position = RTL ^ (position === "start") ? "left" : "right";
}
if (calloutFits(position)) {
return position;
}
let newPosition = Object.keys(positioners)
.filter(p => p !== position)
.find(p => calloutFits(p));
// If the callout doesn't fit in any position, use the configured one.
// The callout will be adjusted to overlap the parent element so that
// the former doesn't go off screen.
return newPosition || position;
}
function centerHorizontally() {
let sideOffset = (parentEl.offsetWidth - container.offsetWidth) / 2;
let containerSide = RTL
? window.innerWidth - getOffset(parentEl).right + sideOffset
: getOffset(parentEl).left + sideOffset;
container.style[RTL ? "right" : "left"] = `${Math.max(containerSide, 0)}px`;
}
clearPosition(container);
let finalPosition = choosePosition();
if (finalPosition) {
positioners[finalPosition].position();
}
container.classList.remove("hidden");
}
function _addPositionListeners() {
window.addEventListener("scroll", _positionCallout);
window.addEventListener("resize", _positionCallout);
}
function _removePositionListeners() {
window.removeEventListener("scroll", _positionCallout);
window.removeEventListener("resize", _positionCallout);
}
function _setupWindowFunctions() {
const AWParent = new lazy.AboutWelcomeParent();
addEventListener("unload", () => {
AWParent.didDestroy();
});
const receive = name => data =>
AWParent.onContentMessage(`AWPage:${name}`, data, document);
// Expose top level functions expected by the bundle.
window.AWGetFeatureConfig = () => CONFIG;
window.AWGetRegion = receive("GET_REGION");
window.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
// Do not send telemetry if message config sets metrics as 'block'.
if (CONFIG?.metrics !== "block") {
window.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
}
window.AWSendToDeviceEmailsSupported = receive(
"SEND_TO_DEVICE_EMAILS_SUPPORTED"
);
window.AWSendToParent = (name, data) => receive(name)(data);
window.AWFinish = _endTour;
}
function _endTour() {
// We don't want focus events that happen during teardown to effect
// SAVED_ACTIVE_ELEMENT
window.removeEventListener("focus", focusHandler, { capture: true });
// wait for fade out transition
let container = document.getElementById(CONTAINER_ID);
container?.classList.add("hidden");
setTimeout(() => {
container?.remove();
_removePositionListeners();
RENDER_OBSERVER?.disconnect();
// Put the focus back to the last place the user focused outside of the
// featureCallout windows.
if (SAVED_ACTIVE_ELEMENT) {
SAVED_ACTIVE_ELEMENT.focus({ focusVisible: true });
}
}, TRANSITION_MS);
}
async function _addScriptsAndRender(container) {
// Add React script
async function getReactReady() {
return new Promise(function(resolve) {
let reactScript = document.createElement("script");
reactScript.src = "resource://activity-stream/vendor/react.js";
container.appendChild(reactScript);
reactScript.addEventListener("load", resolve);
});
}
// Add ReactDom script
async function getDomReady() {
return new Promise(function(resolve) {
let domScript = document.createElement("script");
domScript.src = "resource://activity-stream/vendor/react-dom.js";
container.appendChild(domScript);
domScript.addEventListener("load", resolve);
});
}
// Load React, then React Dom
await getReactReady();
await getDomReady();
// Load the bundle to render the content as configured.
let bundleScript = document.createElement("script");
bundleScript.src =
"resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
container.appendChild(bundleScript);
}
function _observeRender(container) {
RENDER_OBSERVER?.observe(container, { childList: true });
}
async function _loadConfig() {
await lazy.ASRouter.waitForInitialized;
let result = await lazy.ASRouter.sendTriggerMessage({
// triggerId and triggerContext
id: "featureCalloutCheck",
context: { source: document.location.pathname.toLowerCase() },
});
CONFIG = result.message.content;
// Only add an impression if we actually have a message to impress
if (Object.keys(result.message).length) {
lazy.ASRouter.addImpression(result.message);
}
CURRENT_SCREEN = CONFIG?.screens?.[CONFIG?.startScreen || 0];
}
async function _renderCallout() {
let container = _createContainer();
if (container) {
// This results in rendering the Feature Callout
await _addScriptsAndRender(container);
_observeRender(container);
}
}
/**
* Render content based on about:welcome multistage template.
*/
async function showFeatureCallout(messageId) {
await _loadConfig();
if (!CONFIG?.screens?.length) {
return;
}
RENDER_OBSERVER = new MutationObserver(function() {
// Check if the Feature Callout screen has loaded for the first time
if (!READY && document.querySelector(`#${CONTAINER_ID} .screen`)) {
// Once the screen element is added to the DOM, wait for the
// animation frame after next to ensure that _positionCallout
// has access to the rendered screen with the correct height
requestAnimationFrame(() => {
requestAnimationFrame(() => {
READY = true;
_positionCallout();
let container = document.getElementById(CONTAINER_ID);
container.focus();
window.addEventListener("focus", focusHandler, {
capture: true, // get the event before retargeting
});
// Alert screen readers to the presence of the callout
container.setAttribute("role", "alert");
});
});
}
});
_addCalloutLinkElements();
// Add handlers for repositioning callout
_addPositionListeners();
_setupWindowFunctions();
// If user has disabled CFR, don't show any callouts. But make sure we load
// the necessary stylesheets first, since re-enabling CFR should allow
// callouts to be shown without needing to reload. In the future this could
// allow adding a CTA to disable recommendations with a label like "Don't show
// these again" (or potentially a toggle to re-enable them).
if (!lazy.cfrFeaturesUserPref) {
CURRENT_SCREEN = null;
return;
}
await _renderCallout();
}
function focusHandler(e) {
let container = document.getElementById(CONTAINER_ID);
if (!container) {
return;
}
// If focus has fired on the feature callout window itself, or on something
// contained in that window, ignore it, as we can't possibly place the focus
// on it after the callout is closd.
if (
e.target.id === CONTAINER_ID ||
(Node.isInstance(e.target) && container.contains(e.target))
) {
return;
}
// Save this so that if the next focus event is re-entering the popup,
// then we'll put the focus back here where the user left it once we exit
// the feature callout series.
SAVED_ACTIVE_ELEMENT = document.activeElement;
}
window.addEventListener("DOMContentLoaded", () => {
// Get the message id from the feature tour pref
// (If/when this surface is used with other pages,
// add logic to select the correct pref for a given
// page's tour using its location)
showFeatureCallout(lazy.featureTourProgress.message);
});
// When the window is focused, ensure tour is synced with tours in
// any other instances of the parent page
window.addEventListener("visibilitychange", () => {
// If we have more than one screen, it means that we're
// displaying a feature tour, in which transitions are handled
// by the pref change observer.
if (CONFIG?.screens.length > 1) {
_handlePrefChange();
} else {
showFeatureCallout();
}
});