diff --git a/browser/components/asrouter/docs/feature-callout.md b/browser/components/asrouter/docs/feature-callout.md
index b79a0f3c64f3..d9ff0c366d57 100644
--- a/browser/components/asrouter/docs/feature-callout.md
+++ b/browser/components/asrouter/docs/feature-callout.md
@@ -217,6 +217,12 @@ interface FeatureCallout {
// default (this corresponds to a triangle with 24px edges). This
// also affects the height of the arrow.
arrow_width?: number;
+ // By default, callouts are not focused when they are shown. The user
+ // must use a mouse or the F6 shortcut to interact with the callout.
+ // This property allows you to force an element inside the callout to
+ // be focused when the callout is shown. Use sparingly, as it can make
+ // callouts much more disruptive for users.
+ autofocus?: AutoFocusOptions;
}
];
content: {
@@ -410,6 +416,10 @@ interface FeatureCallout {
dismiss?: boolean;
};
}>;
+ // An action to perform when the Escape key is pressed, or when a page
+ // event listener invokes an action containing `dismiss: true`.
+ // Unnecessary if your message has a dismiss_button.
+ dismiss_action?: Action;
};
}>;
// Specify the index of the screen to start on. Generally unused.
@@ -427,6 +437,20 @@ type PopupAttachmentPoint =
| "topcenter"
| "bottomcenter";
+interface AutoFocusOptions {
+ // A preferred CSS selector, if you want a specific element to be focused. If
+ // omitted, the default prioritization listed below will be used, based on
+ // `use_defaults`.
+ // Default prioritization: primary_button, secondary_button, additional_button
+ // (excluding pseudo-links), dismiss_button, , any button.
+ selector?: string;
+ // Whether to use the default element prioritization. If `selector` is
+ // provided and the element can't be found, and this is set to false, nothing
+ // will be selected. If `selector` is not provided, this must be true.
+ // Defaults to true.
+ use_defaults?: boolean;
+}
+
type Label = string | LocalizableThing;
interface LocalizableThing {
diff --git a/browser/components/asrouter/modules/FeatureCallout.sys.mjs b/browser/components/asrouter/modules/FeatureCallout.sys.mjs
index 6e40cfba2da9..c23308a690b2 100644
--- a/browser/components/asrouter/modules/FeatureCallout.sys.mjs
+++ b/browser/components/asrouter/modules/FeatureCallout.sys.mjs
@@ -455,28 +455,16 @@ export class FeatureCallout {
if (!this._container) {
return;
}
- let focusedElement =
- this.context === "chrome"
- ? Services.focus.focusedElement
- : this.doc.activeElement;
- // If the window has a focused element, let it handle the ESC key instead.
- if (
- !focusedElement ||
- focusedElement === this.doc.body ||
- (focusedElement === this.browser && this.theme.simulateContent) ||
- this._container.contains(focusedElement)
- ) {
- this.win.AWSendEventTelemetry?.({
- event: "DISMISS",
- event_context: {
- source: `KEY_${event.key}`,
- page: this.location,
- },
- message_id: this.config?.id.toUpperCase(),
- });
- this._dismiss();
- event.preventDefault();
- }
+ this.win.AWSendEventTelemetry?.({
+ event: "DISMISS",
+ event_context: {
+ source: `KEY_${event.key}`,
+ page: this.location,
+ },
+ message_id: this.config?.id.toUpperCase(),
+ });
+ this._dismiss();
+ event.preventDefault();
break;
}
@@ -641,6 +629,19 @@ export class FeatureCallout {
* @property {String} [bottom]
*/
+ /**
+ * @typedef {Object} AutoFocusOptions For the optional autofocus feature.
+ * @property {String} [selector] A preferred CSS selector, if you want a
+ * specific element to be focused. If omitted, the default prioritization
+ * listed below will be used, based on `use_defaults`.
+ * Default prioritization: primary_button, secondary_button, additional_button
+ * (excluding pseudo-links), dismiss_button, , any button.
+ * @property {Boolean} [use_defaults] Whether to use the default element
+ * prioritization. If `selector` is provided and the element can't be found,
+ * and this is set to false, nothing will be selected. If `selector` is not
+ * provided, this must be true. Defaults to true.
+ */
+
/**
* @typedef {Object} Anchor
* @property {String} selector CSS selector for the anchor node.
@@ -660,6 +661,9 @@ export class FeatureCallout {
* [open] style. Buttons do, for example. It's usually similar to :active.
* @property {Number} [arrow_width] The desired width of the arrow in a number
* of pixels. 33.94113 by default (this corresponds to 24px edges).
+ * @property {AutoFocusOptions} [autofocus] Options for the optional autofocus
+ * feature. Typically omitted, but if provided, an element inside the
+ * callout will be automatically focused when the callout appears.
*/
/**
@@ -1581,7 +1585,9 @@ export class FeatureCallout {
}
_dismiss() {
- let action = this.currentScreen?.content.dismiss_button?.action;
+ let action =
+ this.currentScreen?.content.dismiss_action ??
+ this.currentScreen?.content.dismiss_button?.action;
if (action?.type) {
this.win.AWSendToParent("SPECIAL_ACTION", action);
if (!action.dismiss) {
@@ -1899,33 +1905,43 @@ export class FeatureCallout {
}
/**
- * Get the element that should be initially focused. Prioritize the primary
- * button, then the secondary button, then any additional button, excluding
- * pseudo-links and the dismiss button. If no button is found, focus the first
- * input element. If no affirmative action is found, focus the first button,
- * which is probably the dismiss button. If no button is found, focus the
- * container itself.
+ * Get the element that should be autofocused when the callout first opens. By
+ * default, prioritize the primary button, then the secondary button, then any
+ * additional button, excluding pseudo-links and the dismiss button. If no
+ * button is found, focus the first input element. If no affirmative action is
+ * found, focus the first button, which is probably the dismiss button. A
+ * custom selector can also be provided to focus a specific element.
+ * @param {AutoFocusOptions} [options]
* @returns {Element|null} The element to focus when the callout is shown.
*/
- getInitialFocus() {
+ getAutoFocusElement({ selector, use_defaults = true } = {}) {
if (!this._container) {
return null;
}
- return (
- this._container.querySelector(
- ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
- ) ||
- this._container.querySelector(
- ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
- ) ||
- this._container.querySelector(
- "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)"
- ) ||
- this._container.querySelector("input:not(:disabled, [hidden])") ||
- this._container.querySelector(
- "button:not(:disabled, [hidden], .text-link, .cta-link)"
- )
- );
+ if (selector) {
+ let element = this._container.querySelector(selector);
+ if (element) {
+ return element;
+ }
+ }
+ if (use_defaults) {
+ return (
+ this._container.querySelector(
+ ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
+ ) ||
+ this._container.querySelector(
+ ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
+ ) ||
+ this._container.querySelector(
+ "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)"
+ ) ||
+ this._container.querySelector("input:not(:disabled, [hidden])") ||
+ this._container.querySelector(
+ "button:not(:disabled, [hidden], .text-link, .cta-link)"
+ )
+ );
+ }
+ return null;
}
/**
@@ -1945,13 +1961,16 @@ export class FeatureCallout {
this.renderObserver = new this.win.MutationObserver(() => {
// Check if the Feature Callout screen has loaded for the first time
if (!this.ready && this._container.querySelector(".screen")) {
+ const anchor = this._getAnchor();
const onRender = () => {
this.ready = true;
this._pageEventManager?.clear();
this._attachPageEventListeners(
this.currentScreen?.content?.page_event_listeners
);
- this.getInitialFocus()?.focus();
+ if (anchor?.autofocus) {
+ this.getAutoFocusElement(anchor.autofocus)?.focus();
+ }
this.win.addEventListener("keypress", this, { capture: true });
if (this._container.localName === "div") {
this.win.addEventListener("focus", this, {
@@ -1982,7 +2001,6 @@ export class FeatureCallout {
this.win.requestAnimationFrame(onRender);
});
} else if (this._container.localName === "panel") {
- const anchor = this._getAnchor();
if (!anchor?.panel_position) {
this.endTour();
return;
diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js
index 2501bf37061a..7a43f88b3f07 100644
--- a/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js
+++ b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js
@@ -1123,70 +1123,6 @@ add_task(
}
);
-add_task(async function feature_callout_dismissed_on_escape() {
- const sandbox = sinon.createSandbox();
- const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
- sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
- sendTriggerStub.callThrough();
-
- const win = await BrowserTestUtils.openNewBrowserWindow();
- await openURLInWindow(win, PDF_TEST_URL);
- const doc = win.document;
- await waitForCalloutScreen(doc, testMessageScreenId);
- const container = doc.querySelector(calloutSelector);
- ok(
- container,
- "Feature Callout is rendered in the browser chrome with a new window when a message is available"
- );
-
- // Ensure the browser is focused
- win.gBrowser.selectedBrowser.focus();
-
- // Press Escape to close
- EventUtils.synthesizeKey("KEY_Escape", {}, win);
- await waitForCalloutRemoved(doc);
- ok(true, "Feature callout dismissed after pressing Escape");
-
- await BrowserTestUtils.closeWindow(win);
- sandbox.restore();
-});
-
-add_task(
- async function feature_callout_not_dismissed_on_escape_with_interactive_elm_focused() {
- const sandbox = sinon.createSandbox();
- const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
- sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
- sendTriggerStub.callThrough();
-
- const win = await BrowserTestUtils.openNewBrowserWindow();
- await openURLInWindow(win, PDF_TEST_URL);
- const doc = win.document;
- await waitForCalloutScreen(doc, testMessageScreenId);
- const container = doc.querySelector(calloutSelector);
- ok(
- container,
- "Feature Callout is rendered in the browser chrome with a new window when a message is available"
- );
-
- // Ensure an interactive element is focused
- win.gURLBar.focus();
-
- // Press Escape to close
- EventUtils.synthesizeKey("KEY_Escape", {}, win);
- await TestUtils.waitForTick();
- // Wait 500ms for transition to complete
- // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
- await new Promise(resolve => setTimeout(resolve, 500));
- ok(
- doc.querySelector(calloutSelector),
- "Feature callout is not dismissed after pressing Escape because an interactive element is focused"
- );
-
- await BrowserTestUtils.closeWindow(win);
- sandbox.restore();
- }
-);
-
add_task(async function first_anchor_selected_is_valid() {
const win = await BrowserTestUtils.openNewBrowserWindow();
const config = {
diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js
index 366f900dafdf..617759107629 100644
--- a/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js
+++ b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js
@@ -354,6 +354,65 @@ add_task(async function feature_callout_split_dismiss_button() {
);
});
+// Test that the usual focus behavior works: focus remains where it is when the
+// callout opens, and F6 must be pressed to focus the callout.
+add_task(async function feature_callout_no_autofocus() {
+ requestLongerTimeout(2);
+ let message = getTestMessage();
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const doc = win.document;
+ const browser = win.gBrowser.selectedBrowser;
+
+ win.focus();
+ win.gURLBar.blur();
+ let onFocused = BrowserTestUtils.waitForEvent(
+ win.gURLBar.inputField,
+ "focus"
+ );
+ win.gURLBar.focus();
+ await onFocused;
+ is(doc.activeElement, win.gURLBar.inputField, "URL bar should be focused");
+
+ let focusedElement = doc.activeElement;
+
+ let popupShown = BrowserTestUtils.waitForEvent(doc, "popupshown", true);
+ let calloutShown = waitForCalloutScreen(doc, message.content.screens[0].id);
+ const { featureCallout, showing, closed } = await showFeatureCallout(
+ browser,
+ message
+ );
+
+ await Promise.all([popupShown, calloutShown]);
+ let calloutContainer = featureCallout._container;
+ ok(showing && calloutContainer, "Feature callout should be showing");
+
+ is(
+ doc.activeElement,
+ focusedElement,
+ "Focus should not change when the callout is shown"
+ );
+
+ let dismissButton = doc.querySelector(calloutDismissSelector);
+ ok(dismissButton, "Callout should have a dismiss button");
+ let onFocused2 = BrowserTestUtils.waitForEvent(dismissButton, "focus", true);
+ EventUtils.synthesizeKey("KEY_F6", {}, win);
+ await onFocused2;
+ is(
+ doc.activeElement,
+ dismissButton,
+ "Callout dismiss button should be focused after F6"
+ );
+
+ EventUtils.synthesizeKey("VK_SPACE", {}, win);
+ await closed;
+ await waitForCalloutRemoved(doc);
+ ok(!doc.querySelector(calloutSelector), "Feature callout should be hidden");
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+// Test that the autofocus property causes the callout to be focused when shown,
+// and that Tab and Shift+Tab cycle through elements as expected.
add_task(async function feature_callout_tab_order() {
let message = getTestMessage();
message.content.screens[0].content.secondary_button = {
@@ -364,6 +423,8 @@ add_task(async function feature_callout_tab_order() {
label: { raw: "Advance" },
action: { navigate: true },
};
+ // enable autofocus on the anchor
+ message.content.screens[0].anchors[0].autofocus = {};
await testCalloutHiddenIf(
async (win, calloutContainer) => {