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
500 lines
16 KiB
JavaScript
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();
|
|
}
|
|
});
|