1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
11 CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
12 PageEventManager: "resource://activity-stream/lib/PageEventManager.sys.mjs",
15 XPCOMUtils.defineLazyModuleGetters(lazy, {
16 ASRouter: "resource://activity-stream/lib/ASRouter.jsm",
19 const TRANSITION_MS = 500;
20 const CONTAINER_ID = "feature-callout";
21 const CONTENT_BOX_ID = "multi-stage-message-root";
23 "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
25 XPCOMUtils.defineLazyGetter(lazy, "log", () => {
26 const { Logger } = ChromeUtils.importESModule(
27 "resource://messaging-system/lib/Logger.sys.mjs"
29 return new Logger("FeatureCallout");
33 * Feature Callout fetches messages relevant to a given source and displays them
34 * in the parent page pointing to the element they describe.
36 export class FeatureCallout {
38 * @typedef {Object} FeatureCalloutOptions
39 * @property {Window} win window in which messages will be rendered.
40 * @property {{name: String, defaultValue?: String}} [pref] optional pref used
41 * to track progress through a given feature tour. for example:
43 * name: "browser.pdfjs.feature-tour",
44 * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
46 * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
47 * @property {String} [location] string to pass as the page when requesting
48 * messages from ASRouter and sending telemetry.
49 * @property {String} context either "chrome" or "content". "chrome" is used
50 * when the callout is shown in the browser chrome, and "content" is used
51 * when the callout is shown in a content page like Firefox View.
52 * @property {MozBrowser} [browser] <browser> element responsible for the
53 * feature callout. for content pages, this is the browser element that the
54 * callout is being shown in. for chrome, this is the active browser.
55 * @property {Function} [listener] callback to be invoked on various callout
56 * events to keep the broker informed of the callout's state.
57 * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
60 /** @param {FeatureCalloutOptions} options */
71 this.doc = win.document;
72 this.browser = browser || this.win.docShell.chromeEventHandler;
74 this.loadingConfig = false;
79 this._featureTourProgress = null;
80 this.currentScreen = null;
81 this.renderObserver = null;
82 this.savedFocus = null;
84 this._positionListenersRegistered = false;
85 this._panelConflictListenersRegistered = false;
87 this.location = location;
88 this.context = context;
89 this.listener = listener;
90 this._initTheme(theme);
92 this._handlePrefChange = this._handlePrefChange.bind(this);
94 XPCOMUtils.defineLazyPreferenceGetter(
96 "cfrFeaturesUserPref",
97 "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
100 this.setupFeatureTourProgress();
102 // When the window is focused, ensure tour is synced with tours in any other
103 // instances of the parent page. This does not apply when the Callout is
104 // shown in the browser chrome.
105 if (this.context !== "chrome") {
106 this.win.addEventListener("visibilitychange", this);
109 this.win.addEventListener("unload", this);
112 setupFeatureTourProgress() {
113 if (this.featureTourProgress) {
116 if (this.pref?.name) {
117 this._handlePrefChange(null, null, this.pref.name);
118 Services.prefs.addObserver(this.pref.name, this._handlePrefChange);
122 teardownFeatureTourProgress() {
123 if (this.pref?.name) {
124 Services.prefs.removeObserver(this.pref.name, this._handlePrefChange);
126 this._featureTourProgress = null;
129 get featureTourProgress() {
130 return this._featureTourProgress;
134 * Get the page event manager and instantiate it if necessary. Only used by
135 * _attachPageEventListeners, since we don't want to do this unnecessary work
136 * if a message with page event listeners hasn't loaded. Other consumers
137 * should use `this._pageEventManager?.property` instead.
139 get _loadPageEventManager() {
140 if (!this._pageEventManager) {
141 this._pageEventManager = new lazy.PageEventManager(this.win);
143 return this._pageEventManager;
146 _addPositionListeners() {
147 if (!this._positionListenersRegistered) {
148 this.win.addEventListener("resize", this);
149 this._positionListenersRegistered = true;
153 _removePositionListeners() {
154 if (this._positionListenersRegistered) {
155 this.win.removeEventListener("resize", this);
156 this._positionListenersRegistered = false;
160 _addPanelConflictListeners() {
161 if (!this._panelConflictListenersRegistered) {
162 this.win.addEventListener("popupshowing", this);
163 this.win.gURLBar.controller.addQueryListener(this);
164 this._panelConflictListenersRegistered = true;
168 _removePanelConflictListeners() {
169 if (this._panelConflictListenersRegistered) {
170 this.win.removeEventListener("popupshowing", this);
171 this.win.gURLBar.controller.removeQueryListener(this);
172 this._panelConflictListenersRegistered = false;
177 * Close the tour when the urlbar is opened in the chrome. Set up by
178 * gURLBar.controller.addQueryListener in _addPanelConflictListeners.
184 _handlePrefChange(subject, topic, prefName) {
186 case this.pref?.name:
188 this._featureTourProgress = JSON.parse(
189 Services.prefs.getStringPref(
191 this.pref.defaultValue ?? null
195 this._featureTourProgress = null;
197 if (topic === "nsPref:changed") {
198 this._maybeAdvanceScreens();
204 async _maybeAdvanceScreens() {
205 if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
209 // If we have more than one screen, it means that we're displaying a feature
210 // tour, and transitions are handled based on the value of a tour progress
211 // pref. Otherwise, just show the feature callout. If a pref change results
212 // from an event in a Spotlight message, initialize the feature callout with
213 // the next message in the tour.
215 this.config?.screens.length === 1 ||
216 this.currentScreen == "spotlight"
218 this.showFeatureCallout();
222 let prefVal = this.featureTourProgress;
223 // End the tour according to the tour progress pref or if the user disabled
224 // contextual feature recommendations.
225 if (prefVal.complete || !this.cfrFeaturesUserPref) {
227 } else if (prefVal.screen !== this.currentScreen?.id) {
228 // Pref changes only matter to us insofar as they let us advance an
229 // ongoing tour. If the tour was closed and the pref changed later, e.g.
230 // by editing the pref directly, we don't want to start up the tour again.
231 // This is more important in the chrome, which is always open.
232 if (this.context === "chrome" && !this.currentScreen) {
236 this._container?.classList.toggle(
238 this._container?.localName !== "panel"
240 this._pageEventManager?.emit({
242 target: this._container,
244 const onFadeOut = async () => {
245 // If the initial message was deployed from outside by ASRouter as a
246 // result of a trigger, we can't continue it through _loadConfig, since
247 // that effectively requests a message with a `featureCalloutCheck`
248 // trigger. So we need to load up the same message again, merely
249 // changing the startScreen index. Just check that the next screen and
250 // the current screen are both within the message's screens array.
251 let nextMessage = null;
253 this.context === "chrome" &&
254 this.message?.trigger.id !== "featureCalloutCheck"
257 this.config?.screens.some(s => s.id === this.currentScreen?.id) &&
258 this.config.screens.some(s => s.id === prefVal.screen)
260 nextMessage = this.message;
263 this._container?.remove();
264 this.renderObserver?.disconnect();
265 this._removePositionListeners();
266 this._removePanelConflictListeners();
267 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
269 const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage(
272 if (!isMessageUnblocked) {
277 let updated = await this._updateConfig(nextMessage);
278 if (!updated && !this.currentScreen) {
282 let rendering = await this._renderCallout();
287 if (this._container?.localName === "panel") {
288 this._container.addEventListener("popuphidden", onFadeOut, {
291 this._container.hidePopup(true);
293 this.win.setTimeout(onFadeOut, TRANSITION_MS);
299 switch (event.type) {
301 if (!this._container) {
304 // If focus has fired on the feature callout window itself, or on something
305 // contained in that window, ignore it, as we can't possibly place the focus
306 // on it after the callout is closd.
308 event.target === this._container ||
309 (Node.isInstance(event.target) &&
310 this._container.contains(event.target))
314 // Save this so that if the next focus event is re-entering the popup,
315 // then we'll put the focus back here where the user left it once we exit
316 // the feature callout series.
317 if (this.doc.activeElement) {
318 let element = this.doc.activeElement;
321 focusVisible: element.matches(":focus-visible"),
324 this.savedFocus = null;
330 if (event.key !== "Escape") {
333 if (!this._container) {
337 this.context === "chrome"
338 ? Services.focus.focusedElement
339 : this.doc.activeElement;
340 // If the window has a focused element, let it handle the ESC key instead.
343 focusedElement === this.doc.body ||
344 (focusedElement === this.browser && this.theme.simulateContent) ||
345 this._container.contains(focusedElement)
347 this.win.AWSendEventTelemetry?.({
350 source: `KEY_${event.key}`,
353 message_id: this.config?.id.toUpperCase(),
356 event.preventDefault();
361 case "visibilitychange":
362 this._maybeAdvanceScreens();
367 this.win.requestAnimationFrame(() => this._positionCallout());
371 // If another panel is showing, close the tour.
373 event.target !== this._container &&
374 event.target.localName === "panel" &&
375 event.target.id !== "ctrlTab-panel" &&
376 event.target.ownerGlobal === this.win
383 if (event.target === this._container) {
390 this.teardownFeatureTourProgress();
398 async _addCalloutLinkElements() {
400 "browser/newtab/onboarding.ftl",
401 "browser/spotlight.ftl",
402 "branding/brand.ftl",
403 "toolkit/branding/brandings.ftl",
404 "browser/newtab/asrouter.ftl",
405 "browser/featureCallout.ftl",
407 this.win.MozXULElement.insertFTLIfNeeded(path);
410 const addChromeSheet = async href => {
412 this.win.windowUtils.loadSheetUsingURIString(
414 Ci.nsIDOMWindowUtils.AUTHOR_SHEET
417 // the sheet was probably already loaded. I don't think there's a way to
418 // check for this via JS, but the method checks and throws if it's
419 // already loaded, so we can just treat the error as expected.
422 const addStylesheet = href => {
423 if (this.win.isChromeWindow) {
424 // for chrome, load the stylesheet using a special method to make sure
425 // it's loaded synchronously before the first paint & position.
426 return addChromeSheet(href);
428 if (this.doc.querySelector(`link[href="${href}"]`)) {
431 const link = this.doc.head.appendChild(this.doc.createElement("link"));
432 link.rel = "stylesheet";
436 // Update styling to be compatible with about:welcome bundle
438 "chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
452 * } PopupAttachmentPoint
454 * @see nsMenuPopupFrame
456 * Each attachment point corresponds to an attachment point on the edge of a
457 * frame. For example, "topleft" corresponds to the frame's top left corner,
458 * and "rightcenter" corresponds to the center of the right edge of the frame.
462 * @typedef {Object} PanelPosition Specifies how the callout panel should be
463 * positioned relative to the anchor element, by providing which point on
464 * the callout should be aligned with which point on the anchor element.
465 * @property {PopupAttachmentPoint} anchor_attachment
466 * @property {PopupAttachmentPoint} callout_attachment
467 * @property {Number} [offset_x] Offset in pixels to apply to the callout
468 * position in the horizontal direction.
469 * @property {Number} [offset_y] The same in the vertical direction.
471 * This is used when you want the callout to be displayed as a <panel>
472 * element. A panel is critical when the callout is displayed in the browser
473 * chrome, anchored to an element whose position on the screen is dynamic,
474 * such as a button. When the anchor moves, the panel will automatically move
475 * with it. Also, when the elements are aligned so that the callout would
476 * extend beyond the edge of the screen, the panel will automatically flip
477 * itself to the other side of the anchor element. This requires specifying
478 * both an anchor attachment point and a callout attachment point. For
479 * example, to get the callout to appear under a button, with its arrow on the
480 * right side of the callout:
481 * { anchor_attachment: "bottomcenter", callout_attachment: "topright" }
492 * | "top-center-arrow-end"
493 * | "top-center-arrow-start"
494 * } HTMLArrowPosition
496 * @see FeatureCallout._positionCallout()
497 * The position of the callout arrow relative to the callout container. Only
498 * used for HTML callouts, typically in content pages. If the position
499 * contains a dash, the value before the dash refers to which edge of the
500 * feature callout the arrow points from. The value after the dash describes
501 * where along that edge the arrow sits, with middle as the default.
505 * @typedef {Object} PositionOverride CSS properties to override
506 * the callout's position relative to the anchor element. Although the
507 * callout is not actually a child of the anchor element, this allows
508 * absolute positioning of the callout relative to the anchor element. In
509 * other words, { top: "0px", left: "0px" } will position the callout in the
510 * top left corner of the anchor element, in the same way these properties
511 * would position a child element.
512 * @property {String} [top]
513 * @property {String} [left]
514 * @property {String} [right]
515 * @property {String} [bottom]
519 * @typedef {Object} AnchorConfig
520 * @property {String} selector CSS selector for the anchor node.
521 * @property {PanelPosition} [panel_position] Used to show the callout in a
522 * XUL panel. Only works in chrome documents, like the main browser window.
523 * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in
524 * an HTML div container. Mutually exclusive with panel_position.
525 * @property {PositionOverride} [absolute_position] Only used for HTML
526 * callouts, i.e. when panel_position is not specified. Allows absolute
527 * positioning of the callout relative to the anchor element.
528 * @property {Boolean} [hide_arrow] Whether to hide the arrow.
529 * @property {Boolean} [no_open_on_anchor] Whether to set the [open] style on
530 * the anchor element when the callout is shown. False to set it, true to
531 * not set it. This only works for panel callouts. Not all elements have an
532 * [open] style. Buttons do, for example. It's usually similar to :active.
533 * @property {Number} [arrow_width] The desired width of the arrow in a number
534 * of pixels. 33.94113 by default (this corresponds to 24px edges).
538 * @typedef {Object} Anchor
539 * @property {String} selector
540 * @property {PanelPosition} [panel_position]
541 * @property {HTMLArrowPosition} [arrow_position]
542 * @property {PositionOverride} [absolute_position]
543 * @property {Boolean} [hide_arrow]
544 * @property {Boolean} [no_open_on_anchor]
545 * @property {Number} [arrow_width]
546 * @property {Element} element The anchor node resolved from the selector.
547 * @property {String} [panel_position_string] The panel_position joined into a
548 * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup.
552 * Return the first visible anchor element for the current screen. Screens can
553 * specify multiple anchors in an array, and the first one that is visible
554 * will be used. If none are visible, return null.
555 * @returns {Anchor|null}
558 /** @type {AnchorConfig[]} */
559 const anchors = Array.isArray(this.currentScreen?.anchors)
560 ? this.currentScreen.anchors
562 for (let anchor of anchors) {
563 if (!anchor || typeof anchor !== "object") {
565 `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}`
569 const { selector, arrow_position, panel_position } = anchor;
570 let panel_position_string;
571 if (panel_position) {
572 panel_position_string = this._getPanelPositionString(panel_position);
573 // if the positionString doesn't match the format we expect, don't
574 // render the callout.
575 if (!panel_position_string && !arrow_position) {
579 }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify(
588 !this._HTMLArrowPositions.includes(arrow_position)
593 }: Invalid arrow_position config. Expected one of ${JSON.stringify(
594 this._HTMLArrowPositions
595 )}, got: ${arrow_position}`
599 const element = selector && this.doc.querySelector(selector);
601 continue; // Element doesn't exist at all.
603 const isVisible = () => {
605 this.context === "chrome" &&
606 typeof this.win.isElementVisible === "function"
608 // In chrome windows, we can use the isElementVisible function to
609 // check that the element has non-zero width and height. If it was
610 // hidden, it would most likely have zero width and/or height.
611 if (!this.win.isElementVisible(element)) {
615 // CSS rules like visibility: hidden or display: none. These result in
616 // element being invisible and unclickable.
617 const style = this.win.getComputedStyle(element);
618 return style?.visibility === "visible" && style?.display !== "none";
624 this.context === "chrome" &&
626 anchor.selector.includes("#" + element.id)
628 let widget = lazy.CustomizableUI.getWidget(element.id);
631 (this.win.CustomizationHandler.isCustomizing() ||
632 widget.areaType?.includes("panel"))
634 // The element is a customizable widget (a toolbar item, e.g. the
635 // reload button or the downloads button). Widgets can be in various
636 // areas, like the overflow panel or the customization palette.
637 // Widgets in the palette are present in the chrome's DOM during
638 // customization, but can't be used.
642 return { ...anchor, panel_position_string, element };
647 /** @see PopupAttachmentPoint */
648 _popupAttachmentPoints = [
660 * Return a string representing the position of the panel relative to the
661 * anchor element. Passed to XULPopupElement::openPopup. The string is of the
662 * form "anchor_attachment callout_attachment".
664 * @param {PanelPosition} panelPosition
665 * @returns {String|null} A string like "bottomcenter topright", or null if
666 * the panelPosition object is invalid.
668 _getPanelPositionString(panelPosition) {
669 const { anchor_attachment, callout_attachment } = panelPosition;
671 !this._popupAttachmentPoints.includes(anchor_attachment) ||
672 !this._popupAttachmentPoints.includes(callout_attachment)
676 let positionString = `${anchor_attachment} ${callout_attachment}`;
677 return positionString;
681 * Set/override methods on a panel element. Can be used to override methods on
682 * the custom element class, or to add additional methods.
684 * @param {MozPanel} panel The panel to set methods for
686 _setPanelMethods(panel) {
687 // This method is optionally called by MozPanel::_setSideAttribute, though
688 // it does not exist on the class.
689 panel.setArrowPosition = function setArrowPosition(event) {
690 if (!this.hasAttribute("show-arrow")) {
693 let { alignmentPosition, alignmentOffset, popupAlignment } = event;
694 let positionParts = alignmentPosition?.match(
695 /^(before|after|start|end)_(before|after|start|end)$/
697 if (!positionParts) {
700 // Hide the arrow if the `flip` behavior has caused the panel to
701 // offset relative to its anchor, since the arrow would no longer
702 // point at the true anchor. This differs from an arrow that is
703 // intentionally hidden by the user in message.
704 if (this.getAttribute("hide-arrow") !== "permanent") {
705 if (alignmentOffset) {
706 this.setAttribute("hide-arrow", "temporary");
708 this.removeAttribute("hide-arrow");
711 let arrowPosition = "top";
712 switch (positionParts[1]) {
715 // Inline arrow, i.e. arrow is on one of the left/right edges.
717 this.ownerGlobal.getComputedStyle(this).direction == "rtl";
718 let isRight = isRTL ^ (positionParts[1] == "start");
719 let side = isRight ? "end" : "start";
720 arrowPosition = `inline-${side}`;
721 if (popupAlignment?.includes("center")) {
722 arrowPosition = `inline-${side}`;
723 } else if (positionParts[2] == "before") {
724 arrowPosition = `inline-${side}-top`;
725 } else if (positionParts[2] == "after") {
726 arrowPosition = `inline-${side}-bottom`;
732 // Block arrow, i.e. arrow is on one of the top/bottom edges.
733 let side = positionParts[1] == "before" ? "bottom" : "top";
734 arrowPosition = side;
735 if (popupAlignment?.includes("center")) {
736 arrowPosition = side;
737 } else if (positionParts[2] == "end") {
738 arrowPosition = `${side}-end`;
739 } else if (positionParts[2] == "start") {
740 arrowPosition = `${side}-start`;
745 this.setAttribute("arrow-position", arrowPosition);
750 const anchor = this._getAnchor();
751 // Don't render the callout if none of the anchors is visible.
756 const { autohide, padding } = this.currentScreen.content;
758 panel_position_string,
763 const needsPanel = "MozXULElement" in this.win && !!panel_position_string;
765 if (this._container) {
766 if (needsPanel ^ (this._container?.localName === "panel")) {
767 this._container.remove();
771 if (!this._container?.parentElement) {
773 let fragment = this.win.MozXULElement.parseXULToFragment(`<panel
774 class="panel-no-padding"
780 position="${panel_position_string}"
781 ${hide_arrow ? "" : 'show-arrow=""'}
782 ${autohide ? "" : 'noautohide="true"'}
783 ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""}
785 this._container = fragment.firstElementChild;
786 this._setPanelMethods(this._container);
788 this._container = this.doc.createElement("div");
789 this._container?.classList.add("hidden");
791 this._container.classList.add("featureCallout", "callout-arrow");
793 this._container.setAttribute("hide-arrow", "permanent");
795 this._container.removeAttribute("hide-arrow");
797 this._container.id = CONTAINER_ID;
798 this._container.setAttribute(
800 `#${CONTAINER_ID} .welcome-text`
802 this._container.tabIndex = 0;
804 this._container.style.setProperty("--arrow-width", `${arrow_width}px`);
806 this._container.style.removeProperty("--arrow-width");
809 this._container.style.setProperty("--callout-padding", `${padding}px`);
811 this._container.style.removeProperty("--callout-padding");
813 let contentBox = this.doc.createElement("div");
814 contentBox.id = CONTENT_BOX_ID;
815 contentBox.classList.add("onboardingContainer");
816 // This value is reported as the "page" in about:welcome telemetry
817 contentBox.dataset.page = this.location;
819 if (needsPanel && this.win.isChromeWindow) {
820 this.doc.getElementById("mainPopupSet").appendChild(this._container);
822 this.doc.body.prepend(this._container);
824 const makeArrow = classPrefix => {
825 const arrowRotationBox = this.doc.createElement("div");
826 arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`);
827 const arrow = this.doc.createElement("div");
828 arrow.classList.add("arrow", `${classPrefix}-arrow`);
829 arrowRotationBox.appendChild(arrow);
830 return arrowRotationBox;
832 this._container.appendChild(makeArrow("shadow"));
833 this._container.appendChild(contentBox);
834 this._container.appendChild(makeArrow("background"));
836 return this._container;
839 /** @see HTMLArrowPosition */
840 _HTMLArrowPositions = [
847 "top-center-arrow-end",
848 "top-center-arrow-start",
852 * Set callout's position relative to parent element
855 const container = this._container;
856 const anchor = this._getAnchor();
857 if (!container || !anchor) {
861 const parentEl = anchor.element;
862 const doc = this.doc;
863 const arrowPosition = anchor.arrow_position || "top";
864 const arrowWidth = anchor.arrow_width || 33.94113;
865 const arrowHeight = arrowWidth / 2;
866 const overlapAmount = 5;
867 let overlap = overlapAmount - arrowHeight;
868 // Is the document layout right to left?
869 const RTL = this.doc.dir === "rtl";
870 const customPosition = anchor.absolute_position;
872 const getOffset = el => {
873 const rect = el.getBoundingClientRect();
875 left: rect.left + this.win.scrollX,
876 right: rect.right + this.win.scrollX,
877 top: rect.top + this.win.scrollY,
878 bottom: rect.bottom + this.win.scrollY,
882 const clearPosition = () => {
883 Object.keys(positioners).forEach(position => {
884 container.style[position] = "unset";
886 container.removeAttribute("arrow-position");
889 const setArrowPosition = position => {
896 val = "inline-start";
902 case "top-center-arrow-start":
903 val = RTL ? "top-end" : "top-start";
906 case "top-center-arrow-end":
907 val = RTL ? "top-start" : "top-end";
915 container.setAttribute("arrow-position", val);
918 const addValueToPixelValue = (value, pixelValue) => {
919 return `${parseFloat(pixelValue) + value}px`;
922 const subtractPixelValueFromValue = (pixelValue, value) => {
923 return `${value - parseFloat(pixelValue)}px`;
926 const overridePosition = () => {
927 // We override _every_ positioner here, because we want to manually set
928 // all container.style.positions in every positioner's "position" function
929 // regardless of the actual arrow position
931 // Note: We override the position functions with new functions here, but
932 // they don't actually get executed until the respective position
933 // functions are called and this function is not executed unless the
934 // message has a custom position property.
936 // We're positioning relative to a parent element's bounds, if that parent
939 for (const position in positioners) {
940 positioners[position].position = () => {
941 if (customPosition.top) {
942 container.style.top = addValueToPixelValue(
943 parentEl.getBoundingClientRect().top,
948 if (customPosition.left) {
949 const leftPosition = addValueToPixelValue(
950 parentEl.getBoundingClientRect().left,
955 ? (container.style.right = leftPosition)
956 : (container.style.left = leftPosition);
959 if (customPosition.right) {
960 const rightPosition = subtractPixelValueFromValue(
961 customPosition.right,
962 parentEl.getBoundingClientRect().right -
963 container.getBoundingClientRect().width
967 ? (container.style.right = rightPosition)
968 : (container.style.left = rightPosition);
971 if (customPosition.bottom) {
972 container.style.top = subtractPixelValueFromValue(
973 customPosition.bottom,
974 parentEl.getBoundingClientRect().bottom -
975 container.getBoundingClientRect().height
982 // Remember not to use HTML-only properties/methods like offsetHeight. Try
983 // to use getBoundingClientRect() instead, which is available on XUL
984 // elements. This is necessary to support feature callout in chrome, which
985 // is still largely XUL-based.
986 const positioners = {
987 // availableSpace should be the space between the edge of the page in the
988 // assumed direction and the edge of the parent (with the callout being
989 // intended to fit between those two edges) while needed space should be
990 // the space necessary to fit the callout container.
994 doc.documentElement.clientHeight -
995 getOffset(parentEl).top -
996 parentEl.getBoundingClientRect().height
999 neededSpace: container.getBoundingClientRect().height - overlap,
1001 // Point to an element above the callout
1003 getOffset(parentEl).top +
1004 parentEl.getBoundingClientRect().height -
1006 container.style.top = `${Math.max(0, containerTop)}px`;
1007 alignHorizontally("center");
1012 return getOffset(parentEl).top;
1014 neededSpace: container.getBoundingClientRect().height - overlap,
1016 // Point to an element below the callout
1018 getOffset(parentEl).top -
1019 container.getBoundingClientRect().height +
1021 container.style.top = `${Math.max(0, containerTop)}px`;
1022 alignHorizontally("center");
1027 return getOffset(parentEl).left;
1029 neededSpace: container.getBoundingClientRect().width - overlap,
1031 // Point to an element to the right of the callout
1033 getOffset(parentEl).left -
1034 container.getBoundingClientRect().width +
1036 container.style.left = `${Math.max(0, containerLeft)}px`;
1038 container.getBoundingClientRect().height <=
1039 parentEl.getBoundingClientRect().height
1041 container.style.top = `${getOffset(parentEl).top}px`;
1049 return doc.documentElement.clientWidth - getOffset(parentEl).right;
1051 neededSpace: container.getBoundingClientRect().width - overlap,
1053 // Point to an element to the left of the callout
1055 getOffset(parentEl).left +
1056 parentEl.getBoundingClientRect().width -
1058 container.style.left = `${Math.max(0, containerLeft)}px`;
1060 container.getBoundingClientRect().height <=
1061 parentEl.getBoundingClientRect().height
1063 container.style.top = `${getOffset(parentEl).top}px`;
1072 doc.documentElement.clientHeight -
1073 getOffset(parentEl).top -
1074 parentEl.getBoundingClientRect().height
1077 neededSpace: container.getBoundingClientRect().height - overlap,
1079 // Point to an element above and at the start of the callout
1081 getOffset(parentEl).top +
1082 parentEl.getBoundingClientRect().height -
1084 container.style.top = `${Math.max(0, containerTop)}px`;
1085 alignHorizontally("start");
1091 doc.documentElement.clientHeight -
1092 getOffset(parentEl).top -
1093 parentEl.getBoundingClientRect().height
1096 neededSpace: container.getBoundingClientRect().height - overlap,
1098 // Point to an element above and at the end of the callout
1100 getOffset(parentEl).top +
1101 parentEl.getBoundingClientRect().height -
1103 container.style.top = `${Math.max(0, containerTop)}px`;
1104 alignHorizontally("end");
1107 "top-center-arrow-start": {
1110 doc.documentElement.clientHeight -
1111 getOffset(parentEl).top -
1112 parentEl.getBoundingClientRect().height
1115 neededSpace: container.getBoundingClientRect().height - overlap,
1117 // Point to an element above and at the start of the callout
1119 getOffset(parentEl).top +
1120 parentEl.getBoundingClientRect().height -
1122 container.style.top = `${Math.max(0, containerTop)}px`;
1123 alignHorizontally("center-arrow-start");
1126 "top-center-arrow-end": {
1129 doc.documentElement.clientHeight -
1130 getOffset(parentEl).top -
1131 parentEl.getBoundingClientRect().height
1134 neededSpace: container.getBoundingClientRect().height - overlap,
1136 // Point to an element above and at the end of the callout
1138 getOffset(parentEl).top +
1139 parentEl.getBoundingClientRect().height -
1141 container.style.top = `${Math.max(0, containerTop)}px`;
1142 alignHorizontally("center-arrow-end");
1147 const calloutFits = position => {
1148 // Does callout element fit in this position relative
1149 // to the parent element without going off screen?
1151 // Only consider which edge of the callout the arrow points from,
1152 // not the alignment of the arrow along the edge of the callout
1153 let edgePosition = position.split("-")[0];
1155 positioners[edgePosition].availableSpace() >
1156 positioners[edgePosition].neededSpace
1160 const choosePosition = () => {
1161 let position = arrowPosition;
1162 if (!this._HTMLArrowPositions.includes(position)) {
1163 // Configured arrow position is not valid
1166 if (["start", "end"].includes(position)) {
1167 // position here is referencing the direction that the callout container
1168 // is pointing to, and therefore should be the _opposite_ side of the
1169 // arrow eg. if arrow is at the "end" in LTR layouts, the container is
1170 // pointing at an element to the right of itself, while in RTL layouts
1171 // it is pointing to the left of itself
1172 position = RTL ^ (position === "start") ? "left" : "right";
1174 // If we're overriding the position, we don't need to sort for available space
1175 if (customPosition || (position && calloutFits(position))) {
1178 let sortedPositions = ["top", "bottom", "left", "right"]
1179 .filter(p => p !== position)
1180 .filter(calloutFits)
1183 positioners[b].availableSpace() - positioners[b].neededSpace >
1184 positioners[a].availableSpace() - positioners[a].neededSpace
1187 // If the callout doesn't fit in any position, use the configured one.
1188 // The callout will be adjusted to overlap the parent element so that
1189 // the former doesn't go off screen.
1190 return sortedPositions[0] || position;
1193 const centerVertically = () => {
1195 (container.getBoundingClientRect().height -
1196 parentEl.getBoundingClientRect().height) /
1198 container.style.top = `${getOffset(parentEl).top - topOffset}px`;
1202 * Horizontally align a top/bottom-positioned callout according to the
1204 * @param {String} position one of...
1205 * - "center": for use with top/bottom. arrow is in the center, and the
1206 * center of the callout aligns with the parent center.
1207 * - "center-arrow-start": for use with center-arrow-top-start. arrow is
1208 * on the start (left) side of the callout, and the callout is aligned
1209 * so that the arrow points to the center of the parent element.
1210 * - "center-arrow-end": for use with center-arrow-top-end. arrow is on
1211 * the end, and the arrow points to the center of the parent.
1212 * - "start": currently unused. align the callout's starting edge with the
1213 * parent's starting edge.
1214 * - "end": currently unused. same as start but for the ending edge.
1216 const alignHorizontally = position => {
1220 (parentEl.getBoundingClientRect().width -
1221 container.getBoundingClientRect().width) /
1223 const containerSide = RTL
1224 ? doc.documentElement.clientWidth -
1225 getOffset(parentEl).right +
1227 : getOffset(parentEl).left + sideOffset;
1228 container.style[RTL ? "right" : "left"] = `${Math.max(
1236 const containerSide =
1237 RTL ^ (position === "end")
1238 ? parentEl.getBoundingClientRect().left +
1239 parentEl.getBoundingClientRect().width -
1240 container.getBoundingClientRect().width
1241 : parentEl.getBoundingClientRect().left;
1242 container.style.left = `${Math.max(containerSide, 0)}px`;
1245 case "center-arrow-end":
1246 case "center-arrow-start": {
1247 const parentRect = parentEl.getBoundingClientRect();
1248 const containerWidth = container.getBoundingClientRect().width;
1249 const containerSide =
1250 RTL ^ position.endsWith("end")
1252 parentRect.width / 2 +
1256 : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2;
1257 const maxContainerSide =
1258 doc.documentElement.clientWidth - containerWidth;
1259 container.style.left = `${Math.min(
1261 Math.max(containerSide, 0)
1267 clearPosition(container);
1269 if (customPosition) {
1273 let finalPosition = choosePosition();
1274 if (finalPosition) {
1275 positioners[finalPosition].position();
1276 setArrowPosition(finalPosition);
1279 container.classList.remove("hidden");
1282 /** Expose top level functions expected by the aboutwelcome bundle. */
1283 _setupWindowFunctions() {
1288 const handleActorMessage =
1289 lazy.AboutWelcomeParent.prototype.onContentMessage.bind({});
1290 const getActionHandler = name => data =>
1291 handleActorMessage(`AWPage:${name}`, data, this.doc);
1293 const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT");
1294 const AWSendEventTelemetry = data => {
1295 if (this.config?.metrics !== "block") {
1296 return telemetryMessageHandler(data);
1300 this._windowFuncs = {
1301 AWGetFeatureConfig: () => this.config,
1302 AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"),
1303 // Do not send telemetry if message config sets metrics as 'block'.
1304 AWSendEventTelemetry,
1305 AWSendToDeviceEmailsSupported: getActionHandler(
1306 "SEND_TO_DEVICE_EMAILS_SUPPORTED"
1308 AWSendToParent: (name, data) => getActionHandler(name)(data),
1309 AWFinish: () => this.endTour(),
1310 AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"),
1312 for (const [name, func] of Object.entries(this._windowFuncs)) {
1313 this.win[name] = func;
1316 this.AWSetup = true;
1319 /** Clean up the functions defined above. */
1320 _clearWindowFunctions() {
1322 this.AWSetup = false;
1324 for (const name of Object.keys(this._windowFuncs)) {
1325 delete this.win[name];
1331 * Emit an event to the broker, if one is present.
1332 * @param {String} name
1335 _emitEvent(name, data) {
1336 this.listener?.(this.win, name, data);
1339 endTour(skipFadeOut = false) {
1340 // We don't want focus events that happen during teardown to affect
1342 this.win.removeEventListener("focus", this, {
1346 this.win.removeEventListener("keypress", this, { capture: true });
1347 this._pageEventManager?.emit({
1349 target: this._container,
1351 this._container?.removeEventListener("popuphiding", this);
1352 this._pageEventManager?.clear();
1354 // Delete almost everything to get this ready to show a different message.
1355 this.teardownFeatureTourProgress();
1358 this.message = null;
1359 this.content = null;
1360 this.currentScreen = null;
1361 // wait for fade out transition
1362 this._container?.classList.toggle(
1364 this._container?.localName !== "panel"
1366 this._clearWindowFunctions();
1367 const onFadeOut = () => {
1368 this._container?.remove();
1369 this.renderObserver?.disconnect();
1370 this._removePositionListeners();
1371 this._removePanelConflictListeners();
1372 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
1373 // Put the focus back to the last place the user focused outside of the
1374 // featureCallout windows.
1375 if (this.savedFocus) {
1376 this.savedFocus.element.focus({
1377 focusVisible: this.savedFocus.focusVisible,
1380 this.savedFocus = null;
1381 this._emitEvent("end");
1383 if (this._container?.localName === "panel") {
1384 this._container.addEventListener("popuphidden", onFadeOut, {
1387 this._container.hidePopup(!skipFadeOut);
1388 } else if (this._container) {
1389 this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS);
1396 let action = this.currentScreen?.content.dismiss_button?.action;
1398 this.win.AWSendToParent("SPECIAL_ACTION", action);
1399 if (!action.dismiss) {
1406 async _addScriptsAndRender() {
1407 const reactSrc = "resource://activity-stream/vendor/react.js";
1408 const domSrc = "resource://activity-stream/vendor/react-dom.js";
1410 const getReactReady = async () => {
1411 return new Promise(resolve => {
1412 let reactScript = this.doc.createElement("script");
1413 reactScript.src = reactSrc;
1414 this.doc.head.appendChild(reactScript);
1415 reactScript.addEventListener("load", resolve);
1418 // Add ReactDom script
1419 const getDomReady = async () => {
1420 return new Promise(resolve => {
1421 let domScript = this.doc.createElement("script");
1422 domScript.src = domSrc;
1423 this.doc.head.appendChild(domScript);
1424 domScript.addEventListener("load", resolve);
1427 // Load React, then React Dom
1428 if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
1429 await getReactReady();
1431 if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
1432 await getDomReady();
1434 // Load the bundle to render the content as configured.
1435 this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
1436 let bundleScript = this.doc.createElement("script");
1437 bundleScript.src = BUNDLE_SRC;
1438 this.doc.head.appendChild(bundleScript);
1441 _observeRender(container) {
1442 this.renderObserver?.observe(container, { childList: true });
1446 * Update the internal config with a new message. If a message is not
1447 * provided, try requesting one from ASRouter. The message content is stored
1448 * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome
1449 * bundle will use that function to get the content when it executes.
1450 * @param {Object} [message] ASRouter message. Omit to request a new one.
1451 * @returns {Promise<boolean>} true if a message is loaded, false if not.
1453 async _updateConfig(message) {
1454 if (this.loadingConfig) {
1458 this.message = message || (await this._loadConfig());
1460 switch (this.message.template) {
1461 case "feature_callout":
1464 // Special handling for spotlight messages, which can be configured as a
1465 // kind of introduction to a feature tour.
1466 this.currentScreen = "spotlight";
1472 this.config = this.message.content;
1474 // Set the default start screen.
1475 let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
1476 // If we have a feature tour in progress, try to set the start screen to
1477 // whichever screen is configured in the feature tour pref.
1479 this.config.screens &&
1480 this.config?.tour_pref_name &&
1481 this.config.tour_pref_name === this.pref?.name &&
1482 this.featureTourProgress
1484 const newIndex = this.config.screens.findIndex(
1485 screen => screen.id === this.featureTourProgress.screen
1487 if (newIndex !== -1) {
1488 newScreen = this.config.screens[newIndex];
1489 if (newScreen?.id !== this.currentScreen?.id) {
1490 // This is how we tell the bundle to render the correct screen.
1491 this.config.startScreen = newIndex;
1495 if (newScreen?.id === this.currentScreen?.id) {
1499 this.currentScreen = newScreen;
1504 * Request a message from ASRouter, targeting the `browser` and `page` values
1505 * passed to the constructor.
1506 * @returns {Promise<Object>} the requested message.
1508 async _loadConfig() {
1509 this.loadingConfig = true;
1510 await lazy.ASRouter.waitForInitialized;
1511 let result = await lazy.ASRouter.sendTriggerMessage({
1512 browser: this.browser,
1513 // triggerId and triggerContext
1514 id: "featureCalloutCheck",
1515 context: { source: this.location },
1517 this.loadingConfig = false;
1518 return result.message;
1522 * Try to render the callout in the current document.
1523 * @returns {Promise<Boolean>} whether the callout was rendered.
1525 async _renderCallout() {
1526 this._setupWindowFunctions();
1527 await this._addCalloutLinkElements();
1528 let container = this._createContainer();
1530 // This results in rendering the Feature Callout
1531 await this._addScriptsAndRender();
1532 this._observeRender(container.querySelector("#" + CONTENT_BOX_ID));
1533 if (container.localName === "div") {
1534 this._addPositionListeners();
1542 * For each member of the screen's page_event_listeners array, add a listener.
1543 * @param {Array<PageEventListenerConfig>} listeners
1545 * @typedef {Object} PageEventListenerConfig
1546 * @property {PageEventListenerParams} params Event listener parameters
1547 * @property {PageEventListenerAction} action Sent when the event fires
1549 * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs
1550 * @property {String} type Event type string e.g. `click`
1551 * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]`
1552 * @property {PageEventListenerOptions} [options] addEventListener options
1554 * @typedef {Object} PageEventListenerOptions
1555 * @property {Boolean} [capture] Use event capturing phase?
1556 * @property {Boolean} [once] Remove listener after first event?
1557 * @property {Boolean} [preventDefault] Prevent default action?
1558 * @property {Number} [interval] Used only for `timeout` and `interval` event
1559 * types. These don't set up real event listeners, but instead invoke the
1560 * action on a timer.
1562 * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent
1563 * @property {String} [type] Action type, e.g. `OPEN_URL`
1564 * @property {Object} [data] Extra data, properties depend on action type
1565 * @property {Boolean} [dismiss] Dismiss screen after performing action?
1566 * @property {Boolean} [reposition] Reposition screen after performing action?
1568 _attachPageEventListeners(listeners) {
1569 listeners?.forEach(({ params, action }) =>
1570 this._loadPageEventManager[params.options?.once ? "once" : "on"](
1573 this._handlePageEventAction(action, event);
1574 if (params.options?.preventDefault) {
1575 event.preventDefault?.();
1583 * Perform an action in response to a page event.
1584 * @param {PageEventListenerAction} action
1585 * @param {Event} event Triggering event
1587 _handlePageEventAction(action, event) {
1588 const page = this.location;
1589 const message_id = this.config?.id.toUpperCase();
1591 typeof event.target === "string"
1593 : this._getUniqueElementIdentifier(event.target);
1595 this.win.AWSendEventTelemetry?.({
1596 event: "PAGE_EVENT",
1598 action: action.type,
1599 reason: event.type?.toUpperCase(),
1605 this.win.AWSendToParent("SPECIAL_ACTION", action);
1607 if (action.dismiss) {
1608 this.win.AWSendEventTelemetry?.({
1610 event_context: { source: `PAGE_EVENT:${source}`, page },
1615 if (action.reposition) {
1616 this.win.requestAnimationFrame(() => this._positionCallout());
1621 * For a given element, calculate a unique string that identifies it.
1622 * @param {Element} target Element to calculate the selector for
1623 * @returns {String} Computed event target selector, e.g. `button#next`
1625 _getUniqueElementIdentifier(target) {
1627 if (Element.isInstance(target)) {
1628 source = target.localName;
1629 if (target.className) {
1630 source += `.${[...target.classList].join(".")}`;
1633 source += `#${target.id}`;
1635 if (target.attributes.length) {
1636 source += `${[...target.attributes]
1637 .filter(attr => ["is", "role", "open"].includes(attr.name))
1638 .map(attr => `[${attr.name}="${attr.value}"]`)
1641 if (this.doc.querySelectorAll(source).length > 1) {
1642 let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
1643 if (uniqueAncestor) {
1644 source = `${this._getUniqueElementIdentifier(
1654 * Get the element that should be initially focused. Prioritize the primary
1655 * button, then the secondary button, then any additional button, excluding
1656 * pseudo-links and the dismiss button. If no button is found, focus the first
1657 * input element. If no affirmative action is found, focus the first button,
1658 * which is probably the dismiss button. If no button is found, focus the
1660 * @returns {Element|null} The element to focus when the callout is shown.
1663 if (!this._container) {
1667 this._container.querySelector(
1668 ".primary:not(:disabled, [hidden], .text-link, .cta-link)"
1670 this._container.querySelector(
1671 ".secondary:not(:disabled, [hidden], .text-link, .cta-link)"
1673 this._container.querySelector(
1674 "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button)"
1676 this._container.querySelector("input:not(:disabled, [hidden])") ||
1677 this._container.querySelector(
1678 "button:not(:disabled, [hidden], .text-link, .cta-link)"
1685 * Show a feature callout message, either by requesting one from ASRouter or
1686 * by showing a message passed as an argument.
1687 * @param {Object} [message] optional message to show instead of requesting one
1688 * @returns {Promise<Boolean>} true if a message was shown
1690 async showFeatureCallout(message) {
1691 let updated = await this._updateConfig(message);
1693 if (!updated || !this.config?.screens?.length) {
1694 return !!this.currentScreen;
1697 if (!this.renderObserver) {
1698 this.renderObserver = new this.win.MutationObserver(() => {
1699 // Check if the Feature Callout screen has loaded for the first time
1700 if (!this.ready && this._container.querySelector(".screen")) {
1701 const onRender = () => {
1703 this._pageEventManager?.clear();
1704 this._attachPageEventListeners(
1705 this.currentScreen?.content?.page_event_listeners
1707 this.getInitialFocus()?.focus();
1708 this.win.addEventListener("keypress", this, { capture: true });
1709 if (this._container.localName === "div") {
1710 this.win.addEventListener("focus", this, {
1711 capture: true, // get the event before retargeting
1714 this._positionCallout();
1716 this._container.classList.remove("hidden");
1720 this._container.localName === "div" &&
1721 this.doc.activeElement &&
1724 let element = this.doc.activeElement;
1727 focusVisible: element.matches(":focus-visible"),
1730 // Once the screen element is added to the DOM, wait for the
1731 // animation frame after next to ensure that _positionCallout
1732 // has access to the rendered screen with the correct height
1733 if (this._container.localName === "div") {
1734 this.win.requestAnimationFrame(() => {
1735 this.win.requestAnimationFrame(onRender);
1737 } else if (this._container.localName === "panel") {
1738 const anchor = this._getAnchor();
1743 const position = anchor.panel_position_string;
1744 this._container.addEventListener("popupshown", onRender, {
1747 this._container.addEventListener("popuphiding", this);
1748 this._addPanelConflictListeners();
1749 this._container.openPopup(anchor.element, { position });
1755 this._pageEventManager?.clear();
1757 this._container?.remove();
1758 this.renderObserver?.disconnect();
1760 if (!this.cfrFeaturesUserPref) {
1765 let rendering = (await this._renderCallout()) && !!this.currentScreen;
1770 if (this.message.template) {
1771 lazy.ASRouter.addImpression(this.message);
1777 * @typedef {Object} FeatureCalloutTheme An object with a set of custom color
1778 * schemes and/or a preset key. If both are provided, the preset will be
1779 * applied first, then the custom themes will override the preset values.
1780 * @property {String} [preset] Key of {@link FeatureCallout.themePresets}
1781 * @property {ColorScheme} [light] Custom light scheme
1782 * @property {ColorScheme} [dark] Custom dark scheme
1783 * @property {ColorScheme} [hcm] Custom high contrast scheme
1784 * @property {ColorScheme} [all] Custom scheme that will be applied in all
1785 * cases, but overridden by the other schemes if they are present. This is
1786 * useful if the values are already controlled by the browser theme.
1787 * @property {Boolean} [simulateContent] Set to true if the feature callout
1788 * exists in the browser chrome but is meant to be displayed over the
1789 * content area to appear as if it is part of the page. This will cause the
1790 * styles to use a media query targeting the content instead of the chrome,
1791 * so that if the browser theme doesn't match the content color scheme, the
1792 * callout will correctly follow the content scheme. This is currently used
1793 * for the feature callouts displayed over the PDF.js viewer.
1797 * @typedef {Object} ColorScheme An object with key-value pairs, with keys
1798 * from {@link FeatureCallout.themePropNames}, mapped to CSS color values
1802 * Combine the preset and custom themes into a single object and store it.
1803 * @param {FeatureCalloutTheme} theme
1806 /** @type {FeatureCalloutTheme} */
1807 this.theme = Object.assign(
1809 FeatureCallout.themePresets[theme.preset],
1815 * Apply all the theme colors to the feature callout's root element as CSS
1816 * custom properties in inline styles. These custom properties are consumed by
1817 * _feature-callout-theme.scss, which is bundled with the other styles that
1818 * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}.
1821 if (this._container) {
1822 // This tells the stylesheets to use -moz-content-prefers-color-scheme
1823 // instead of prefers-color-scheme, in order to follow the content color
1824 // scheme instead of the chrome color scheme, in case of a mismatch when
1825 // the feature callout exists in the chrome but is meant to look like it's
1826 // part of the content of a page in a browser tab (like PDF.js).
1827 this._container.classList.toggle(
1829 !!this.theme.simulateContent
1831 for (const type of ["light", "dark", "hcm"]) {
1832 const scheme = this.theme[type];
1833 for (const name of FeatureCallout.themePropNames) {
1834 this._setThemeVariable(
1835 `--fc-${name}-${type}`,
1836 scheme?.[name] || this.theme.all?.[name]
1844 * Set or remove a CSS custom property on the feature callout container
1845 * @param {String} name Name of the CSS custom property
1846 * @param {String|void} [value] Value of the property, or omit to remove it
1848 _setThemeVariable(name, value) {
1850 this._container.style.setProperty(name, value);
1852 this._container.style.removeProperty(name);
1856 /** A list of all the theme properties that can be set */
1857 static themePropNames = [
1862 "button-background",
1865 "button-background-hover",
1866 "button-color-hover",
1867 "button-border-hover",
1868 "button-background-active",
1869 "button-color-active",
1870 "button-border-active",
1871 "primary-button-background",
1872 "primary-button-color",
1873 "primary-button-border",
1874 "primary-button-background-hover",
1875 "primary-button-color-hover",
1876 "primary-button-border-hover",
1877 "primary-button-background-active",
1878 "primary-button-color-active",
1879 "primary-button-border-active",
1882 "link-color-active",
1885 /** @type {Object<String, FeatureCalloutTheme>} */
1886 static themePresets = {
1887 // For themed system pages like New Tab and Firefox View. Themed content
1888 // colors inherit from the user's theme through contentTheme.js.
1891 background: "var(--newtab-background-color-secondary)",
1892 color: "var(--newtab-text-primary-color, var(--in-content-page-color))",
1894 "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)",
1895 "accent-color": "var(--in-content-primary-button-background)",
1896 "button-background": "color-mix(in srgb, transparent 93%, #000)",
1898 "var(--newtab-text-primary-color, var(--in-content-page-color))",
1899 "button-border": "transparent",
1900 "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
1901 "button-color-hover":
1902 "var(--newtab-text-primary-color, var(--in-content-page-color))",
1903 "button-border-hover": "transparent",
1904 "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
1905 "button-color-active":
1906 "var(--newtab-text-primary-color, var(--in-content-page-color))",
1907 "button-border-active": "transparent",
1908 "primary-button-background":
1909 "var(--in-content-primary-button-background)",
1910 "primary-button-color": "var(--in-content-primary-button-text-color)",
1911 "primary-button-border":
1912 "var(--in-content-primary-button-border-color)",
1913 "primary-button-background-hover":
1914 "var(--in-content-primary-button-background-hover)",
1915 "primary-button-color-hover":
1916 "var(--in-content-primary-button-text-color-hover)",
1917 "primary-button-border-hover":
1918 "var(--in-content-primary-button-border-hover)",
1919 "primary-button-background-active":
1920 "var(--in-content-primary-button-background-active)",
1921 "primary-button-color-active":
1922 "var(--in-content-primary-button-text-color-active)",
1923 "primary-button-border-active":
1924 "var(--in-content-primary-button-border-active)",
1925 "link-color": "LinkText",
1926 "link-color-hover": "LinkText",
1927 "link-color-active": "ActiveText",
1928 "link-color-visited": "VisitedText",
1932 "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)",
1933 "button-background": "color-mix(in srgb, transparent 80%, #000)",
1934 "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
1935 "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
1938 background: "-moz-dialog",
1939 color: "-moz-dialogtext",
1940 border: "-moz-dialogtext",
1941 "accent-color": "LinkText",
1942 "button-background": "ButtonFace",
1943 "button-color": "ButtonText",
1944 "button-border": "ButtonText",
1945 "button-background-hover": "ButtonText",
1946 "button-color-hover": "ButtonFace",
1947 "button-border-hover": "ButtonText",
1948 "button-background-active": "ButtonText",
1949 "button-color-active": "ButtonFace",
1950 "button-border-active": "ButtonText",
1953 // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css
1957 color: "rgb(12, 12, 13)",
1959 "accent-color": "#0A84FF",
1960 "button-background": "rgb(215, 215, 219)",
1961 "button-color": "rgb(12, 12, 13)",
1962 "button-border": "transparent",
1963 "button-background-hover": "rgb(221, 222, 223)",
1964 "button-color-hover": "rgb(12, 12, 13)",
1965 "button-border-hover": "transparent",
1966 "button-background-active": "rgb(221, 222, 223)",
1967 "button-color-active": "rgb(12, 12, 13)",
1968 "button-border-active": "transparent",
1969 // use default primary button colors in _feature-callout-theme.scss
1970 "link-color": "LinkText",
1971 "link-color-hover": "LinkText",
1972 "link-color-active": "ActiveText",
1973 "link-color-visited": "VisitedText",
1976 background: "#1C1B22",
1979 "button-background": "rgb(74, 74, 79)",
1980 "button-color": "#F9F9FA",
1981 "button-background-hover": "rgb(102, 102, 103)",
1982 "button-color-hover": "#F9F9FA",
1983 "button-background-active": "rgb(102, 102, 103)",
1984 "button-color-active": "#F9F9FA",
1987 background: "-moz-dialog",
1988 color: "-moz-dialogtext",
1989 border: "CanvasText",
1990 "accent-color": "Highlight",
1991 "button-background": "ButtonFace",
1992 "button-color": "ButtonText",
1993 "button-border": "ButtonText",
1994 "button-background-hover": "Highlight",
1995 "button-color-hover": "CanvasText",
1996 "button-border-hover": "Highlight",
1997 "button-background-active": "Highlight",
1998 "button-color-active": "CanvasText",
1999 "button-border-active": "Highlight",
2004 background: "var(--newtab-background-color-secondary, #FFF)",
2005 color: "var(--newtab-text-primary-color, WindowText)",
2007 "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)",
2008 "accent-color": "#0061e0",
2009 "button-background": "color-mix(in srgb, transparent 93%, #000)",
2010 "button-color": "var(--newtab-text-primary-color, WindowText)",
2011 "button-border": "transparent",
2012 "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
2013 "button-color-hover": "var(--newtab-text-primary-color, WindowText)",
2014 "button-border-hover": "transparent",
2015 "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
2016 "button-color-active": "var(--newtab-text-primary-color, WindowText)",
2017 "button-border-active": "transparent",
2018 // use default primary button colors in _feature-callout-theme.scss
2019 "link-color": "rgb(0, 97, 224)",
2020 "link-color-hover": "rgb(0, 97, 224)",
2021 "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)",
2022 "link-color-visited": "rgb(0, 97, 224)",
2025 "accent-color": "rgb(0, 221, 255)",
2026 background: "var(--newtab-background-color-secondary, #42414D)",
2028 "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)",
2029 "button-background": "color-mix(in srgb, transparent 80%, #000)",
2030 "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
2031 "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
2032 "link-color": "rgb(0, 221, 255)",
2033 "link-color-hover": "rgb(0,221,255)",
2034 "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)",
2035 "link-color-visited": "rgb(0, 221, 255)",
2038 background: "-moz-dialog",
2039 color: "-moz-dialogtext",
2040 border: "-moz-dialogtext",
2041 "accent-color": "SelectedItem",
2042 "button-background": "ButtonFace",
2043 "button-color": "ButtonText",
2044 "button-border": "ButtonText",
2045 "button-background-hover": "ButtonText",
2046 "button-color-hover": "ButtonFace",
2047 "button-border-hover": "ButtonText",
2048 "button-background-active": "ButtonText",
2049 "button-color-active": "ButtonFace",
2050 "button-border-active": "ButtonText",
2051 "link-color": "LinkText",
2052 "link-color-hover": "LinkText",
2053 "link-color-active": "ActiveText",
2054 "link-color-visited": "VisitedText",
2057 // These colors are intended to inherit the user's theme properties from the
2058 // main chrome window, for callouts to be anchored to chrome elements.
2059 // Specific schemes aren't necessary since the theme and frontend
2060 // stylesheets handle these variables' values.
2063 background: "var(--arrowpanel-background)",
2064 color: "var(--arrowpanel-color)",
2065 border: "var(--arrowpanel-border-color)",
2066 "accent-color": "var(--focus-outline-color)",
2067 "button-background": "var(--button-bgcolor)",
2068 "button-color": "var(--button-color)",
2069 "button-border": "transparent",
2070 "button-background-hover": "var(--button-hover-bgcolor)",
2071 "button-color-hover": "var(--button-color)",
2072 "button-border-hover": "transparent",
2073 "button-background-active": "var(--button-active-bgcolor)",
2074 "button-color-active": "var(--button-color)",
2075 "button-border-active": "transparent",
2076 "primary-button-background": "var(--button-primary-bgcolor)",
2077 "primary-button-color": "var(--button-primary-color)",
2078 "primary-button-border": "transparent",
2079 "primary-button-background-hover":
2080 "var(--button-primary-hover-bgcolor)",
2081 "primary-button-color-hover": "var(--button-primary-color)",
2082 "primary-button-border-hover": "transparent",
2083 "primary-button-background-active":
2084 "var(--button-primary-active-bgcolor)",
2085 "primary-button-color-active": "var(--button-primary-color)",
2086 "primary-button-border-active": "transparent",
2087 "link-color": "LinkText",
2088 "link-color-hover": "LinkText",
2089 "link-color-active": "ActiveText",
2090 "link-color-visited": "VisitedText",