Bug 2064747 - Combine a urlbar result's context menu and three-dot menu. r=daisuke,fluent-reviewers,urlbar-reviewers,bolsson

With contextMenu.featureGate off, the three-dot menu is unchanged.

Differential Revision: https://phabricator.services.mozilla.com/D324402
This commit is contained in:
Dão Gottwald
2026-09-09 06:50:09 +00:00
committed by dgottwald@mozilla.com
parent 7386a62c9e
commit bd204a6887
7 changed files with 316 additions and 177 deletions
@@ -161,22 +161,6 @@
class="urlbar-container"
removable="false"
overflows="false">
<html:panel-list id="urlbarView-context-menu" suppress-contextmenu="">
<html:panel-item id="urlbar-view-context-menu-open-in-tab"
data-l10n-id="urlbar-view-context-menu-open-in-tab2"
data-l10n-attrs="accesskey"></html:panel-item>
<html:panel-item submenu="urlbarView-context-menu-open-in-container-tab-submenu"
id="urlbarView-context-menu-open-in-container-tab-menu">
<html:span data-l10n-id="urlbar-view-context-menu-open-in-container-tab2"></html:span>
<html:panel-list slot="submenu" id="urlbarView-context-menu-open-in-container-tab-submenu"></html:panel-list>
</html:panel-item>
<html:panel-item id="urlbarView-context-menu-open-in-window"
data-l10n-id="urlbar-view-context-menu-open-in-window2"
data-l10n-attrs="accesskey"></html:panel-item>
<html:panel-item id="urlbarView-context-menu-open-in-private-window"
data-l10n-id="urlbar-view-context-menu-open-in-private-window2"
data-l10n-attrs="accesskey"></html:panel-item>
</html:panel-list>
<toolbartabstop/>
<html:moz-urlbar id="urlbar"
class="urlbar"
@@ -1714,6 +1714,17 @@ ${
this.pickResult({ result, event, element });
}
/**
* Whether pickResult() implements the result menu's commands for opening a
* result in a new tab or window. The container-tab submenu is built by a
* chrome window helper, so a bar hosted in a content page can't offer them.
*
* @returns {boolean}
*/
get handlesOpenInCommands() {
return typeof this.window.createUserContextMenu == "function";
}
/**
* Called when a result is picked.
*
@@ -1820,34 +1831,31 @@ ${
private: this.isPrivate,
};
let isContextMenu = event
?.composedPath()
.some(el => el.id == "urlbarView-context-menu");
let userContextId = element?.dataset.usercontextid;
let openIn = userContextId ? "container-tab" : element?.dataset.openIn;
if (isContextMenu) {
switch (element.id) {
case "urlbar-view-context-menu-open-in-tab": {
if (openIn) {
switch (openIn) {
case "tab": {
where = "tab";
break;
}
case "urlbarView-context-menu-open-in-window": {
where = "window";
break;
}
case "urlbarView-context-menu-open-in-private-window": {
where = "window";
openParams.private = true;
break;
}
default: {
// Open in a container tab.
case "container-tab": {
where = "tab";
openParams.userContextId = parseInt(
element.getAttribute("data-usercontextid")
);
openParams.userContextId = parseInt(userContextId);
openParams.eventDetail = {
containerSource: "urlbar_result_context_menu",
};
break;
}
case "window": {
where = "window";
break;
}
case "private-window": {
where = "window";
openParams.private = true;
break;
}
}
@@ -5429,9 +5437,7 @@ ${
}
// Don't close the view when clicking on a tab; we may want to keep the
// view open on tab switch, and the TabSelect event arrived earlier.
// Also ignore mousedown on the urlbarView context menu: opening/closing the
// view is already handled by the result opening flow.
if (event.target.closest?.("tab, #urlbarView-context-menu")) {
if (event.target.closest?.("tab")) {
break;
}
+138 -99
View File
@@ -28,13 +28,6 @@ const RESULT_MENU_COMMANDS = {
MANAGE: "manage",
};
// The context menu items that we handle with pickResult.
const CONTEXT_MENU_OPEN_IDS = new Set([
"urlbar-view-context-menu-open-in-tab",
"urlbarView-context-menu-open-in-window",
"urlbarView-context-menu-open-in-private-window",
]);
const getBoundsWithoutFlushing = UrlbarShared.getBoundsWithoutFlushing;
// Used to get a unique id to use for row elements, it wraps at 9999, that
@@ -1208,18 +1201,12 @@ export class UrlbarView {
}
isResultMenuOpen() {
return (
this.resultMenu.hasAttribute("open") ||
// Also checks the contextmenu but will be addressed by:
// https://bugzilla.mozilla.org/show_bug.cgi?id=2064747
!!this.#contextMenu?.hasAttribute("open")
);
return this.resultMenu.hasAttribute("open");
}
// Private properties and methods below.
#announceTabToSearchOnSelection;
#blobUrlsByResultUrl = null;
#contextMenu;
#containerWidthOnLastClose = 0;
#l10nCache;
#mousedownSelectedElement;
@@ -4150,11 +4137,73 @@ export class UrlbarView {
return idArgs;
}
/**
* The commands that open a result in a new tab or window. They're offered
* alongside a result's own menu commands, gated on `contextMenu.featureGate`.
*
* @returns {UrlbarResultCommand[]}
*/
get #openInCommands() {
/** @type {UrlbarResultCommand[]} */
let commands = [
{
openIn: "tab",
l10n: { id: "urlbar-view-context-menu-open-in-tab2" },
},
];
if (
!this.input.isPrivate &&
UrlbarPrefs.get("privacy.userContext.enabled")
) {
commands.push({
openIn: "container-tab",
submenu: true,
l10n: { id: "urlbar-view-context-menu-open-in-container-tab2" },
});
}
commands.push(
{
openIn: "window",
l10n: { id: "urlbar-view-context-menu-open-in-window2" },
},
{
openIn: "private-window",
l10n: { id: "urlbar-view-context-menu-open-in-private-window2" },
}
);
return commands;
}
/**
* @param {UrlbarResult} result
* The result to get menu commands for.
* @returns {?UrlbarResultCommand[]}
* Everything the result's menu shows, null if it has nothing to show. The
* three-dot button and a right-click both open this menu, so it combines
* the result's own commands with the ones that open it in a new tab or
* window.
*/
#getMenuCommands(result) {
let commands = this.#getResultMenuCommands(result);
if (
!UrlbarPrefs.get("contextMenu.featureGate") ||
!this.input.handlesOpenInCommands ||
!UrlbarShared.getLoadRequestFromResult(result)
) {
return commands;
}
let openInCommands = this.#openInCommands;
return commands
? [...openInCommands, { name: "separator" }, ...commands]
: openInCommands;
}
/**
* @param {UrlbarResult} result
* The result to get menu commands for.
* @returns {Array}
* Array of menu commands available for the result, null if there are none.
* Array of the result's own menu commands, null if there are none. This
* also decides whether the result's row gets a three-dot button.
*/
#getResultMenuCommands(result) {
if (this.#resultMenuCommands.has(result)) {
@@ -4217,9 +4266,31 @@ export class UrlbarView {
continue;
}
let menuitem = this.document.createElement("panel-item");
menuitem.dataset.command = data.name;
menuitem.classList.add("urlbarView-result-menuitem");
this.#l10nCache.setElementL10n(menuitem, data.l10n);
if (data.openIn) {
menuitem.dataset.openIn = data.openIn;
} else {
menuitem.dataset.command = data.name;
}
if (data.submenu) {
// The submenu is populated when it's about to be shown, so that it
// doesn't go stale. Fluent replaces the contents of the element it
// localizes, so the label goes in a child and its accesskey stays on
// the item, where pressing it activates the item.
menuitem.toggleAttribute("submenu", true);
let label = this.document.createElement("span");
this.#l10nCache.setElementL10n(label, data.l10n);
if (label.hasAttribute("accesskey")) {
menuitem.setAttribute("accesskey", label.getAttribute("accesskey"));
label.removeAttribute("accesskey");
}
menuitem.appendChild(label);
let submenu = this.document.createElement("panel-list");
submenu.slot = "submenu";
menuitem.appendChild(submenu);
} else {
this.#l10nCache.setElementL10n(menuitem, data.l10n);
}
panel.appendChild(menuitem);
}
}
@@ -4523,81 +4594,47 @@ export class UrlbarView {
}
on_click(event) {
if (event.currentTarget == this.resultMenu) {
let result = this.#resultMenuResult;
this.#resultMenuResult = null;
let menuitem = event.target;
switch (menuitem.dataset.command) {
case RESULT_MENU_COMMANDS.HELP:
menuitem.dataset.url =
result.payload.helpUrl ||
UrlbarContentUtils.getSupportUrl("awesome-bar-result-menu");
break;
}
this.input.pickResult({ result, event, element: menuitem });
} else if (event.currentTarget == this.#contextMenu) {
let target = event
.composedPath()
.find(node => node.localName == "panel-item");
if (
!target ||
(!CONTEXT_MENU_OPEN_IDS.has(target.id) && !target.dataset.usercontextid)
) {
return;
}
let row = this.#contextMenu.lastAnchorNode?.closest(".urlbarView-row");
if (!row) {
return;
}
this.input.pickResult({
result: row.result,
event,
element: target,
});
let menuitem = event
.composedPath()
.find(node => node.localName == "panel-item");
if (
!menuitem ||
// Clicking a submenu's parent item only opens the submenu.
menuitem.hasSubmenu ||
// The container submenu also holds items that manage containers, which
// bring up their own UI instead of picking the result.
!(
menuitem.dataset.command ||
menuitem.dataset.openIn ||
menuitem.dataset.usercontextid
)
) {
return;
}
let result = this.#resultMenuResult;
this.#resultMenuResult = null;
if (menuitem.dataset.command == RESULT_MENU_COMMANDS.HELP) {
menuitem.dataset.url =
result.payload.helpUrl ||
UrlbarContentUtils.getSupportUrl("awesome-bar-result-menu");
}
this.input.pickResult({ result, event, element: menuitem });
}
on_showing(event) {
if (event.target.id == "urlbarView-context-menu") {
let row = this.#contextMenu.lastAnchorNode.closest(".urlbarView-row");
// Set the menu-trigger attribute on the row so it can be styled
// as if it were hovered while the context menu is open.
row.toggleAttribute("menu-trigger", true);
let loadRequest = UrlbarShared.getLoadRequestFromResult(row.result, {
element: row,
});
for (let item of event.target.querySelectorAll("panel-item")) {
item.disabled = !loadRequest;
}
let containerTabItem = event.target.querySelector(
"#urlbarView-context-menu-open-in-container-tab-menu"
);
if (containerTabItem) {
containerTabItem.hidden =
this.input.isPrivate ||
!UrlbarPrefs.get("privacy.userContext.enabled");
}
} else if (
event.target.id == "urlbarView-context-menu-open-in-container-tab-menu"
) {
event.target.documentGlobal.createUserContextMenu(event, {
target: event.target.submenuPanel,
isContextMenu: true,
isPanelList: true,
containerSource: "urlbar_result_context_menu",
});
} else if (event.currentTarget == this.resultMenu) {
let commands;
let splitButton = event.target.triggeringEvent.detail.target.closest(
".urlbarView-splitbutton"
);
if (event.target == this.resultMenu) {
// Set the menu-trigger attribute on the row so it can be styled as if it
// were hovered while the menu is open.
this.resultMenu.lastAnchorNode
.closest(".urlbarView-row")
.toggleAttribute("menu-trigger", true);
let triggeringEvent = this.resultMenu.triggeringEvent;
let splitButton =
triggeringEvent.type == "ResultMenuTriggered" &&
triggeringEvent.detail.target.closest(".urlbarView-splitbutton");
let commands;
if (splitButton) {
// Show the commands the are defined in its Split Button.
let mainButton = splitButton.firstElementChild;
@@ -4606,10 +4643,17 @@ export class UrlbarView {
b => b.name == buttonName
).menu;
} else {
commands = this.#getResultMenuCommands(this.#resultMenuResult);
commands = this.#getMenuCommands(this.#resultMenuResult);
}
this.#populateResultMenu({ commands });
} else if (event.target.dataset.openIn == "container-tab") {
this.chromeWindow.createUserContextMenu(event, {
target: event.target.submenuPanel,
isContextMenu: true,
isPanelList: true,
containerSource: "urlbar_result_context_menu",
});
}
}
@@ -4626,31 +4670,26 @@ export class UrlbarView {
// The context menu associated with this event is either for something above
// the urlbar in the DOM, like the toolbar, or for something specific in the
// input, like the `<html:input>`. We want to suppress the former, propagate
// the latter, and open our own context menu for events on rows.
// the latter, and open the result menu for events on rows.
if (event.target.closest(".urlbar-input-container")) {
return;
}
event.preventDefault();
if (
!UrlbarPrefs.get("contextMenu.featureGate") ||
!event.target.closest(".urlbarView-row")
) {
// Don't show the context menu from the background or the group label etc.
if (!UrlbarPrefs.get("contextMenu.featureGate")) {
return;
}
if (!this.#contextMenu) {
this.#contextMenu = this.document.querySelector(
"#urlbarView-context-menu"
);
this.#contextMenu.addEventListener("click", this);
this.#contextMenu.addEventListener("showing", this);
this.#contextMenu.addEventListener("hidden", this);
// Don't open the menu from the background or the group label etc., nor for
// a result that has nothing to show in it.
let row = event.target.closest(".urlbarView-row");
if (!row || !this.#getMenuCommands(row.result)) {
return;
}
this.#contextMenu.toggle(event);
this.#resultMenuResult = row.result;
this.resultMenu.toggle(event);
}
clearTopSitesCache() {
@@ -26,33 +26,33 @@ add_task(async function basic() {
const TEST_CASES = [
{
preferences: [["browser.tabs.loadInBackground", true]],
menuItemId: "urlbar-view-context-menu-open-in-tab",
openIn: "tab",
expectedTarget: "tab",
expectedOption: { background: true },
},
{
preferences: [["browser.tabs.loadInBackground", false]],
menuItemId: "urlbar-view-context-menu-open-in-tab",
openIn: "tab",
expectedTarget: "tab",
},
{
preferences: [["browser.tabs.loadInBackground", true]],
menuItemId: "urlbarView-context-menu-open-in-container-tab-menu",
openIn: "container-tab",
expectedTarget: "tab",
expectedOption: { background: true, userContextId: 1 },
},
{
preferences: [["browser.tabs.loadInBackground", false]],
menuItemId: "urlbarView-context-menu-open-in-container-tab-menu",
openIn: "container-tab",
expectedTarget: "tab",
expectedOption: { userContextId: 3 },
},
{
menuItemId: "urlbarView-context-menu-open-in-window",
openIn: "window",
expectedTarget: "window",
},
{
menuItemId: "urlbarView-context-menu-open-in-private-window",
openIn: "private-window",
expectedTarget: "window",
expectedOption: { private: true },
},
@@ -60,13 +60,11 @@ add_task(async function basic() {
for (let {
preferences = [],
menuItemId,
openIn,
expectedTarget,
expectedOption = {},
} of TEST_CASES) {
info(
`Test for ${JSON.stringify({ preferences, menuItemId, expectedOption })}`
);
info(`Test for ${JSON.stringify({ preferences, openIn, expectedOption })}`);
info("Set preferences");
await SpecialPowers.pushPrefEnv({ set: preferences });
@@ -76,9 +74,9 @@ add_task(async function basic() {
? BrowserTestUtils.waitForNewTab(gBrowser, "https://example.com/")
: BrowserTestUtils.waitForNewWindow({ url: "https://example.com/" });
let contextMenu = await openContextMenuOnFirstResult();
let menuItem = contextMenu.querySelector(`#${menuItemId}`);
Assert.ok(menuItem, `Found the menu item ${menuItemId}`);
let menu = await openContextMenuOnFirstResult();
let menuItem = menu.querySelector(`[data-open-in="${openIn}"]`);
Assert.ok(menuItem, `Found the menu item for ${openIn}`);
if (expectedOption.userContextId) {
let subMenuItem = await openContainerSubMenuItem(
@@ -138,6 +136,72 @@ add_task(async function basic() {
await PlacesUtils.history.clear();
});
// The three-dot button and a right-click open the same menu, and it holds the
// result's own commands as well as the ones that open it in a new target.
add_task(async function same_menu_from_both_triggers() {
await PlacesTestUtils.addVisits(["https://example.com/"]);
let resultIndex = await promiseResultWithMenuButton();
let { element } = await UrlbarTestUtils.getDetailsOfResultAt(
window,
resultIndex
);
await UrlbarTestUtils.openResultMenu(window, { resultIndex, byMouse: true });
let fromMenuButton = await promiseMenuDescription();
gURLBar.view.resultMenu.hide(undefined, { force: true });
let menu = await openContextMenu(element.row);
let fromContextMenu = await promiseMenuDescription();
menu.hide(undefined, { force: true });
Assert.deepEqual(
fromContextMenu,
fromMenuButton,
"Both triggers open the same menu"
);
Assert.deepEqual(
fromMenuButton.filter(item => item.openIn),
["tab", "container-tab", "window", "private-window"].map(openIn => ({
openIn,
})),
"The menu opens the result in a new target"
);
Assert.ok(
fromMenuButton.some(item => item.command),
"The menu keeps the result's own commands"
);
gURLBar.view.close();
await PlacesUtils.history.clear();
});
// With the feature gate off, the three-dot menu holds only the result's own
// commands and a right-click opens nothing.
add_task(async function feature_gate_off() {
await SpecialPowers.pushPrefEnv({
set: [["browser.urlbar.contextMenu.featureGate", false]],
});
await PlacesTestUtils.addVisits(["https://example.com/"]);
let resultIndex = await promiseResultWithMenuButton();
await UrlbarTestUtils.openResultMenu(window, { resultIndex, byMouse: true });
let items = await promiseMenuDescription();
Assert.ok(
items.some(item => item.command),
"The menu holds the result's own commands"
);
Assert.ok(
items.every(item => !item.openIn),
"The menu doesn't open the result in a new target"
);
gURLBar.view.resultMenu.hide(undefined, { force: true });
gURLBar.view.close();
await PlacesUtils.history.clear();
await SpecialPowers.popPrefEnv();
});
add_task(async function toolbar_context_menu() {
let TEST_TARGETS = [
".searchmode-switcher",
@@ -220,7 +284,7 @@ add_task(async function no_context_menu() {
});
add_task(async function keep_view_open_on_context_menu_mousedown() {
let contextMenu = await openContextMenuOnFirstResult();
let menu = await openContextMenuOnFirstResult();
Assert.ok(
gURLBar.view.isOpen,
"The view should remain open after the context menu is shown"
@@ -228,7 +292,7 @@ add_task(async function keep_view_open_on_context_menu_mousedown() {
info("Mouse down on a context menu item");
EventUtils.synthesizeMouseAtCenter(
contextMenu.querySelector("#urlbar-view-context-menu-open-in-tab"),
menu.querySelector('[data-open-in="tab"]'),
{ type: "mousedown" }
);
@@ -237,10 +301,64 @@ add_task(async function keep_view_open_on_context_menu_mousedown() {
"The view stays open after a mousedown on the context menu"
);
contextMenu.hide(undefined, { force: true });
menu.hide(undefined, { force: true });
gURLBar.view.close();
});
// Returns the menu's items as the command or open-in target each one picks, in
// the order they are shown, separators included.
async function promiseMenuDescription() {
let menu = gURLBar.view.resultMenu;
await TestUtils.waitForCondition(
() => menu.children.length,
"Waiting for the menu to be populated"
);
return [...menu.children].map(item => {
if (item.localName == "hr") {
return "separator";
}
let { command, openIn } = item.dataset;
return command ? { command } : { openIn };
});
}
// Searches for "example" and returns the index of a result that has a menu
// button, which is also a result the menu can open in a new target.
async function promiseResultWithMenuButton() {
await UrlbarTestUtils.promiseAutocompleteResultPopup({
value: "example",
window,
fireInputEvent: true,
});
for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
let { element, url } = await UrlbarTestUtils.getDetailsOfResultAt(
window,
i
);
if (url && element.row.hasAttribute("has-menu-button")) {
return i;
}
}
throw new Error("No result with a menu button");
}
async function openContextMenu(row) {
info("Open the context menu");
let menu = gURLBar.view.resultMenu;
let onShown = BrowserTestUtils.waitForEvent(menu, "shown");
EventUtils.synthesizeMouseAtCenter(row, {
button: 2,
type: "mousedown",
});
EventUtils.synthesizeMouseAtCenter(row, {
button: 2,
type: "contextmenu",
});
await onShown;
return menu;
}
async function openContextMenuOnFirstResult() {
info("Open urlbar results");
await UrlbarTestUtils.promiseAutocompleteResultPopup({
@@ -249,21 +367,7 @@ async function openContextMenuOnFirstResult() {
fireInputEvent: true,
});
let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
info("Open context menu");
let contextMenu = document.getElementById("urlbarView-context-menu");
let onShown = BrowserTestUtils.waitForEvent(contextMenu, "shown");
EventUtils.synthesizeMouseAtCenter(element.row, {
button: 2,
type: "mousedown",
});
EventUtils.synthesizeMouseAtCenter(element.row, {
button: 2,
type: "contextmenu",
});
await onShown;
return contextMenu;
return openContextMenu(element.row);
}
// Opens the submenu of the given item, the same way hovering it does, and
+12 -5
View File
@@ -22,9 +22,9 @@ type UrlbarResult = import("../content/UrlbarResult.mjs").UrlbarResult;
*/
type UrlbarResultCommand = {
/**
* The name of the command. Must be specified unless `children` is present.
* When a command is picked, its name will be passed as `details.selType` to
* `onEngagement()`. The special name "separator" will create a menu separator.
* The name of the command. When a command is picked, its name will be passed
* as `details.selType` to `onEngagement()`. The special name "separator" will
* create a menu separator.
*/
name?: string;
/**
@@ -33,7 +33,14 @@ type UrlbarResultCommand = {
*/
l10n?: L10nIdArgs;
/**
* If specified, a submenu will be created with the given child commands.
* Where the command opens the result, for the view's own commands. Passed to
* `pickResult()` in place of a `name`, so that these picks are recorded as
* ordinary result picks.
*/
children?: UrlbarResultCommand[];
openIn?: "tab" | "container-tab" | "window" | "private-window";
/**
* Whether the command's menu item holds a submenu, populated when it's about
* to be shown.
*/
submenu?: boolean;
};
+1 -1
View File
@@ -934,7 +934,7 @@ urlbar-result-action-switch-to-tabgroup = Switch to { $group }
# $group (String): the name of the tab group to re-open
urlbar-result-action-open-saved-tabgroup = Open { $group }
## Used in the context menu in urlbar view.
## Used in the menu of a urlbar result.
urlbar-view-context-menu-open-in-tab2 = Open in New Tab
.accesskey = w
+1 -2
View File
@@ -537,8 +537,7 @@
/* This should be supported within panel-{item,list} rather than modifying it, to be fixed by
https://bugzilla.mozilla.org/show_bug.cgi?id=1826841 */
.urlbarView-result-menuitem::part(button),
#urlbarView-context-menu panel-item::part(button) {
.urlbarView-result-menuitem::part(button) {
padding-inline-start: 12px;
}