Bug 1845333 - Add submenu items to open tabs more menu r=mstriemer,fxview-reviewers,fluent-reviewers,desktop-theme-reviewers,reusable-components-reviewers,sfoster,flod,tgiles

* Modify panel-list and panel-item to support submenu list items
* Add submenu items for Move Tabs and Send Tabs to Devices to open tabs
* Add test coverage for submenu items in open tabs

Differential Revision: https://phabricator.services.mozilla.com/D186471
This commit is contained in:
Sarah Clements 2023-09-20 16:14:58 +00:00
Родитель ec323f2b5e
Коммит ec86095ac6
13 изменённых файлов: 672 добавлений и 77 удалений

Просмотреть файл

@ -163,6 +163,10 @@ panel-item::part(button):hover:active {
background-color: var(--fxview-element-background-active);
}
panel-list {
overflow-y: visible;
}
fxview-category-navigation {
overflow-y: auto;
}

Просмотреть файл

@ -15,8 +15,19 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
UIState: "resource://services-sync/UIState.sys.mjs",
TabsSetupFlowManager:
"resource:///modules/firefox-view-tabs-setup-manager.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
return ChromeUtils.importESModule(
"resource://gre/modules/FxAccounts.sys.mjs"
).getFxAccountsSingleton();
});
const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
/**
* A collection of open tabs grouped by window.
*
@ -37,6 +48,8 @@ class OpenTabsInView extends ViewPage {
this.isPrivateWindow = lazy.PrivateBrowsingUtils.isWindowPrivate(
this.currentWindow
);
this.boundObserve = (...args) => this.observe(...args);
this.devices = [];
}
connectedCallback() {
@ -69,10 +82,46 @@ class OpenTabsInView extends ViewPage {
}
);
this._updateOpenTabsList();
this.addObserversIfNeeded();
this.devices = this.currentWindow.gSync.getSendTabTargets();
}
disconnectedCallback() {
lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId);
this.removeObserversIfNeeded();
}
addObserversIfNeeded() {
if (!this.observerAdded) {
Services.obs.addObserver(this.boundObserve, lazy.UIState.ON_UPDATE);
Services.obs.addObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED);
this.observerAdded = true;
}
}
removeObserversIfNeeded() {
if (this.observerAdded) {
Services.obs.removeObserver(this.boundObserve, lazy.UIState.ON_UPDATE);
Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED);
this.observerAdded = false;
}
}
async observe(subject, topic, data) {
switch (topic) {
case lazy.UIState.ON_UPDATE:
if (!this.devices.length && lazy.TabsSetupFlowManager.fxaSignedIn) {
this.devices = this.currentWindow.gSync.getSendTabTargets();
}
break;
case TOPIC_DEVICELIST_UPDATED:
const deviceListUpdated =
await lazy.fxAccounts.device.refreshDeviceList();
if (deviceListUpdated) {
this.devices = this.currentWindow.gSync.getSendTabTargets();
}
}
}
render() {
@ -93,6 +142,7 @@ class OpenTabsInView extends ViewPage {
otherWindows.push([index++, tabs, win]);
}
});
const cardClasses = classMap({
"height-limited": this.windows.size > 3,
"width-limited": this.windows.size > 1,
@ -130,6 +180,7 @@ class OpenTabsInView extends ViewPage {
data-l10n-args="${JSON.stringify({
winID: currentWindowIndex,
})}"
.devices=${this.devices}
></view-opentabs-card>
`
)}
@ -142,6 +193,7 @@ class OpenTabsInView extends ViewPage {
data-inner-id="${win.windowGlobalChild.innerWindowId}"
data-l10n-id="firefoxview-opentabs-window-header"
data-l10n-args="${JSON.stringify({ winID })}"
.devices=${this.devices}
></view-opentabs-card>
`
)}
@ -150,8 +202,8 @@ class OpenTabsInView extends ViewPage {
}
/**
* Render a template for the 'Recent browsing' page, which shows a single list of
* recently accessed tabs, rather than a list of tabs per window.
* Render a template for the 'Recent browsing' page, which shows a shorter list of
* open tabs in the current window.
*
* @returns {TemplateResult}
* The recent browsing template.
@ -163,6 +215,7 @@ class OpenTabsInView extends ViewPage {
return html`<view-opentabs-card
.tabs=${tabs}
.recentBrowsing=${true}
.devices=${this.devices}
></view-opentabs-card>`;
}
@ -245,6 +298,8 @@ class OpenTabsInViewCard extends ViewPage {
tabs: { type: Array },
title: { type: String },
recentBrowsing: { type: Boolean },
devices: { type: Array },
triggerNode: { type: Object },
};
static MAX_TABS_FOR_COMPACT_HEIGHT = 7;
@ -254,6 +309,7 @@ class OpenTabsInViewCard extends ViewPage {
this.tabs = [];
this.title = "";
this.recentBrowsing = false;
this.devices = [];
}
static queries = {
@ -264,11 +320,81 @@ class OpenTabsInViewCard extends ViewPage {
closeTab(e) {
const tab = this.triggerNode.tabElement;
const browserWindow = tab.ownerGlobal;
browserWindow.gBrowser.removeTab(tab, { animate: true });
tab?.ownerGlobal.gBrowser.removeTab(tab);
this.recordContextMenuTelemetry("close-tab", e);
}
moveTabsToStart() {
const tab = this.triggerNode.tabElement;
tab?.ownerGlobal.gBrowser.moveTabsToStart(tab);
}
moveTabsToEnd() {
const tab = this.triggerNode.tabElement;
tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab);
}
moveTabsToWindow() {
const tab = this.triggerNode.tabElement;
tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab);
}
moveMenuTemplate() {
const tab = this.triggerNode?.tabElement;
const browserWindow = tab?.ownerGlobal;
const position = tab?._tPos;
const tabs = browserWindow?.gBrowser.tabs || [];
return html`
<panel-list slot="submenu" id="move-tab-menu">
${position > 0
? html`<panel-item
@click=${this.moveTabsToStart}
data-l10n-id="fxviewtabrow-move-tab-start"
data-l10n-attrs="accesskey"
></panel-item>`
: null}
${position < tabs.length - 1
? html`<panel-item
@click=${this.moveTabsToEnd}
data-l10n-id="fxviewtabrow-move-tab-end"
data-l10n-attrs="accesskey"
></panel-item>`
: null}
<panel-item
@click=${this.moveTabsToWindow}
data-l10n-id="fxviewtabrow-move-tab-window"
data-l10n-attrs="accesskey"
></panel-item>
</panel-list>
`;
}
async sendTabToDevice(e) {
let deviceId = e.target.getAttribute("device-id");
let device = this.devices.find(dev => dev.id == deviceId);
if (device && this.triggerNode) {
await this.getWindow().gSync.sendTabToDevice(
this.triggerNode.url,
[device],
this.triggerNode.title
);
}
}
sendTabTemplate() {
return html` <panel-list slot="submenu" id="send-tab-menu">
${this.devices.map(device => {
return html`
<panel-item @click=${this.sendTabToDevice} device-id=${device.id}
>${device.name}</panel-item
>
`;
})}
</panel-list>`;
}
panelListTemplate() {
return html`
<panel-list slot="menu" data-tab-type="opentabs">
@ -277,11 +403,27 @@ class OpenTabsInViewCard extends ViewPage {
data-l10n-attrs="accesskey"
@click=${this.closeTab}
></panel-item>
<panel-item
data-l10n-id="fxviewtabrow-move-tab"
data-l10n-attrs="accesskey"
submenu="move-tab-menu"
>
${this.moveMenuTemplate()}
</panel-item>
<hr />
<panel-item
data-l10n-id="fxviewtabrow-copy-link"
data-l10n-attrs="accesskey"
@click=${this.copyLink}
></panel-item>
${this.devices.length >= 1
? html`<panel-item
data-l10n-id="fxviewtabrow-send-tab"
data-l10n-attrs="accesskey"
submenu="send-tab-menu"
>${this.sendTabTemplate()}</panel-item
>`
: null}
</panel-list>
`;
}

Просмотреть файл

@ -27,6 +27,12 @@ async function setupWithDesktopDevices(state = UIState.STATUS_SIGNED_IN) {
return sandbox;
}
function promiseSyncReady() {
let service = Cc["@mozilla.org/weave/service;1"].getService(
Ci.nsISupports
).wrappedJSObject;
return service.whenLoaded();
}
async function tearDown(sandbox) {
sandbox?.restore();
Services.prefs.clearUserPref("services.sync.lastTabFetch");

Просмотреть файл

@ -4,6 +4,7 @@ support-files = ../head.js
[browser_firefoxview_next.js]
[browser_firefoxview_next_general_telemetry.js]
[browser_history_firefoxview_next.js]
[browser_opentabs_firefoxview_next.js]
[browser_recentlyclosed_firefoxview_next.js]
[browser_syncedtabs_errors_firefoxview_next.js]
[browser_syncedtabs_firefoxview_next.js]

Просмотреть файл

@ -230,7 +230,11 @@ add_task(async function test_context_menu_telemetry() {
await EventUtils.synthesizeMouseAtCenter(firstItem.buttonEl, {}, content);
await BrowserTestUtils.waitForEvent(panelList, "shown");
await clearAllParentTelemetryEvents();
let copyLinkOption = panelList.children[1];
let copyLinkOption = panelList.querySelector(
"panel-item[data-l10n-id=fxviewtabrow-copy-link]"
);
ok(copyLinkOption, "Copy link panel item exists");
let contextMenuEvent = [
[
"firefoxview_next",

Просмотреть файл

@ -27,26 +27,6 @@ oneMonthAgo.setMonth(
oneMonthAgo.getMonth() === 0 ? 11 : oneMonthAgo.getMonth() - 1
);
function isElInViewport(element) {
const boundingRect = element.getBoundingClientRect();
return (
boundingRect.top >= 0 &&
boundingRect.left >= 0 &&
boundingRect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
boundingRect.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
async function openFirefoxView(win) {
await BrowserTestUtils.synthesizeMouseAtCenter(
"#firefox-view-button",
{ type: "mousedown" },
win.browsingContext
);
}
async function addHistoryItems(dateAdded) {
await PlacesUtils.history.insert({
url: URLs[0],

Просмотреть файл

@ -0,0 +1,274 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/* import-globals-from ../head.js */
const TEST_URL1 = "about:robot";
const TEST_URL2 = "https://example.org/";
const TEST_URL3 = "about:mozilla";
const fxaDevicesWithCommands = [
{
id: 1,
name: "My desktop device",
availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "test" },
lastAccessTime: Date.now(),
},
{
id: 2,
name: "My mobile device",
availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "boo" },
lastAccessTime: Date.now() + 60000, // add 30min
},
];
function getCards(openTabs) {
return openTabs.shadowRoot.querySelectorAll("view-opentabs-card");
}
function getRowsForCard(card) {
return card.tabList.rowEls;
}
async function moreMenuSetup(document) {
await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL2);
await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL3);
// once we've opened a few tabs, navigate to the open tabs section in firefox view
await clickFirefoxViewButton(window);
navigateToCategory(document, "opentabs");
let openTabs = document.querySelector("view-opentabs[name=opentabs]");
let cards;
await TestUtils.waitForCondition(() => {
cards = getCards(openTabs);
return cards.length == 1;
});
is(cards.length, 1, "There is one open window.");
let rows = getRowsForCard(cards[0]);
is(rows.length, 3, "There are three tabs in the open tabs list.");
let firstTab = rows[0];
firstTab.scrollIntoView();
is(
isElInViewport(firstTab),
true,
"first tab list item is visible in viewport"
);
return [cards, rows];
}
add_task(async function test_more_menus() {
await withFirefoxView({}, async browser => {
const { document } = browser.contentWindow;
let win = browser.ownerGlobal;
gBrowser.selectedTab = gBrowser.visibleTabs[0];
ok(
gBrowser.selectedTab.linkedBrowser.currentURI.spec == "about:blank",
"Selected tab is about:blank"
);
win.gURLBar.focus();
win.gURLBar.value = TEST_URL1;
EventUtils.synthesizeKey("KEY_Enter", {}, win);
let [cards, rows] = await moreMenuSetup(document);
let firstTab = rows[0];
let panelList = cards[0].shadowRoot.querySelector(
"panel-list:not([submenu])"
);
// click on the first list items button element (more menu)
// and wait for the panel list to be shown
let shown = BrowserTestUtils.waitForEvent(panelList, "shown");
firstTab.buttonEl.click();
await shown;
// Close Tab menu item
info("Panel list shown. Clicking on panel-item");
let panelItem = cards[0].shadowRoot.querySelector(
"panel-item[data-l10n-id=fxviewtabrow-close-tab]"
);
ok(panelItem, "Close Tab panel item exists");
// close a tab via the menu
panelItem.click();
let visibleTabs = gBrowser.visibleTabs;
is(visibleTabs.length, 2, "Expected to now have 2 open tabs");
await cards[0].getUpdateComplete();
// Move Tab submenu item
firstTab = rows[0];
is(firstTab.url, TEST_URL2, `First tab list item is ${TEST_URL2}`);
is(
visibleTabs[0].linkedBrowser.currentURI.spec,
TEST_URL2,
`First tab in tab strip is ${TEST_URL2}`
);
is(
visibleTabs[visibleTabs.length - 1].linkedBrowser.currentURI.spec,
TEST_URL3,
`Last tab in tab strip is ${TEST_URL3}`
);
let moveTabsPanelItem = cards[0].shadowRoot.querySelector(
"panel-item[data-l10n-id=fxviewtabrow-move-tab]"
);
let moveTabsSubmenuList = moveTabsPanelItem.shadowRoot.querySelector(
"panel-list[id=move-tab-menu]"
);
ok(moveTabsSubmenuList, "Move tabs submenu panel list exists");
// click on the first list items button element (more menu)
// and wait for the panel list to be shown again
shown = BrowserTestUtils.waitForEvent(panelList, "shown");
firstTab.buttonEl.click();
await shown;
// navigate down to the "Move tabs" submenu option, and
// open it with the right arrow key
EventUtils.synthesizeKey("KEY_ArrowDown", {});
shown = BrowserTestUtils.waitForEvent(moveTabsSubmenuList, "shown");
EventUtils.synthesizeKey("KEY_ArrowRight", {});
await shown;
// click on the first option, which should be "Move to the end" since
// this is the first tab
EventUtils.synthesizeKey("KEY_Enter", {});
visibleTabs = gBrowser.visibleTabs;
is(
visibleTabs[0].linkedBrowser.currentURI.spec,
TEST_URL3,
`First tab in tab strip is now ${TEST_URL3}`
);
is(
visibleTabs[visibleTabs.length - 1].linkedBrowser.currentURI.spec,
TEST_URL2,
`Last tab in tab strip is now ${TEST_URL2}`
);
// this entire "move tabs" submenu test can be reordered above
// closing a tab since it very clearly reveals the issues
// outlined in bug 1852622 when there are 3 or more tabs open
// and one is moved via the more menus.
await BrowserTestUtils.waitForMutationCondition(
cards[0].shadowRoot,
{ characterData: true, childList: true, subtree: true },
() => {
rows = getRowsForCard(cards[0]);
firstTab = rows[0];
return firstTab.url == TEST_URL3;
}
);
// Copy Link menu item (copyLink function that's called is a member of Viewpage.mjs)
shown = BrowserTestUtils.waitForEvent(panelList, "shown");
firstTab.buttonEl.click();
await shown;
panelItem = cards[0].shadowRoot.querySelector(
"panel-item[data-l10n-id=fxviewtabrow-copy-link]"
);
ok(panelItem, "Copy link panel item exists");
panelItem.click();
let copiedText = SpecialPowers.getClipboardData(
"text/plain",
Ci.nsIClipboard.kGlobalClipboard
);
is(copiedText, TEST_URL3, "The correct url has been copied and pasted");
while (gBrowser.tabs.length > 1) {
BrowserTestUtils.removeTab(gBrowser.tabs[0]);
}
});
});
add_task(async function test_send_device_submenu() {
const sandbox = setupMocks({
state: UIState.STATUS_SIGNED_IN,
fxaDevices: [
{
id: 1,
name: "This Device",
isCurrentDevice: true,
type: "desktop",
tabs: [],
},
],
});
sandbox
.stub(gSync, "getSendTabTargets")
.callsFake(() => fxaDevicesWithCommands);
await withFirefoxView({}, async browser => {
const { document } = browser.contentWindow;
Services.obs.notifyObservers(null, UIState.ON_UPDATE);
let [cards, rows] = await moreMenuSetup(document);
let firstTab = rows[0];
let panelList = cards[0].shadowRoot.querySelector(
"panel-list:not([submenu])"
);
await cards[0].getUpdateComplete();
// click on the first list items button element (more menu)
// and wait for the panel list to be shown
let shown = BrowserTestUtils.waitForEvent(panelList, "shown");
firstTab.buttonEl.click();
await shown;
let sendTabPanelItem = cards[0].shadowRoot.querySelector(
"panel-item[data-l10n-id=fxviewtabrow-send-tab]"
);
ok(sendTabPanelItem, "Send tabs to device submenu panel item exists");
let sendTabSubmenuList = sendTabPanelItem.shadowRoot.querySelector(
"panel-list[id=send-tab-menu]"
);
ok(sendTabSubmenuList, "Send tabs to device submenu panel list exists");
// navigate down to the "Send tabs" submenu option, and
// open it with the right arrow key
EventUtils.synthesizeKey("KEY_ArrowDown", {});
EventUtils.synthesizeKey("KEY_ArrowDown", {});
EventUtils.synthesizeKey("KEY_ArrowDown", {});
shown = BrowserTestUtils.waitForEvent(sendTabSubmenuList, "shown");
EventUtils.synthesizeKey("KEY_ArrowRight", {});
await shown;
let expectation = sandbox
.mock(gSync)
.expects("sendTabToDevice")
.once()
.withExactArgs(
TEST_URL2,
[fxaDevicesWithCommands[0]],
"mochitest index /"
)
.returns(true);
// click on the first device and verify it was "sent"
EventUtils.synthesizeKey("KEY_Enter", {});
expectation.verify();
sandbox.restore();
TabsSetupFlowManager.resetInternalState();
while (gBrowser.tabs.length > 1) {
BrowserTestUtils.removeTab(gBrowser.tabs[0]);
}
});
});

Просмотреть файл

@ -17,26 +17,6 @@ const DISMISS_CLOSED_TAB_EVENT = [
];
const initialTab = gBrowser.selectedTab;
function isElInViewport(element) {
const boundingRect = element.getBoundingClientRect();
return (
boundingRect.top >= 0 &&
boundingRect.left >= 0 &&
boundingRect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
boundingRect.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
async function openFirefoxView(win) {
await BrowserTestUtils.synthesizeMouseAtCenter(
"#firefox-view-button",
{ type: "mousedown" },
win.browsingContext
);
}
async function waitForRecentlyClosedTabsList(doc) {
let recentlyClosedComponent = doc.querySelector(
"view-recentlyclosed:not([slot=recentlyclosed])"
@ -313,7 +293,7 @@ add_task(async function test_list_updates() {
);
SessionStore.undoCloseById(closedTabItem.closedId);
await promiseClosedObjectsChanged;
await openFirefoxView(window);
await clickFirefoxViewButton(window);
// we expect the last item to be removed
expectedURLs.pop();
@ -339,7 +319,7 @@ add_task(async function test_list_updates() {
);
SessionStore.forgetClosedWindowById(closedTabItem.sourceClosedId);
await promiseClosedObjectsChanged;
await openFirefoxView(window);
await clickFirefoxViewButton(window);
listItems = listElem.rowEls;
expectedURLs.shift(); // we expect to have removed the firsts URL from the list
@ -378,7 +358,7 @@ add_task(async function test_restore_tab() {
await clearAllParentTelemetryEvents();
await restore_tab(closeTabItem, browser, closeTabItem.url);
await recentlyClosedTelemetry();
await openFirefoxView(window);
await clickFirefoxViewButton(window);
listItems = listElem.rowEls;
is(listItems.length, 3, "Three tabs are shown in the list.");
@ -387,7 +367,7 @@ add_task(async function test_restore_tab() {
await clearAllParentTelemetryEvents();
await restore_tab(closeTabItem, browser, closeTabItem.url);
await recentlyClosedTelemetry();
await openFirefoxView(window);
await clickFirefoxViewButton(window);
listItems = listElem.rowEls;
is(listItems.length, 2, "Two tabs are shown in the list.");

Просмотреть файл

@ -605,3 +605,25 @@ function navigateToCategory(document, category) {
async function switchToFxViewTab(win = window) {
return BrowserTestUtils.switchTab(win.gBrowser, win.FirefoxViewHandler.tab);
}
function isElInViewport(element) {
const boundingRect = element.getBoundingClientRect();
return (
boundingRect.top >= 0 &&
boundingRect.left >= 0 &&
boundingRect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
boundingRect.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
// TODO once we port over old tests, helpers and cleanup old firefox view
// we should decide whether to keep this or openFirefoxViewTab.
async function clickFirefoxViewButton(win) {
await BrowserTestUtils.synthesizeMouseAtCenter(
"#firefox-view-button",
{ type: "mousedown" },
win.browsingContext
);
}

Просмотреть файл

@ -43,6 +43,16 @@ fxviewtabrow-copy-link = Copy Link
.accesskey = L
fxviewtabrow-close-tab = Close Tab
.accesskey = C
fxviewtabrow-move-tab = Move Tab
.accesskey = v
fxviewtabrow-move-tab-start = Move to Start
.accesskey = S
fxviewtabrow-move-tab-end = Move to End
.accesskey = E
fxviewtabrow-move-tab-window = Move to New Window
.accesskey = W
fxviewtabrow-send-tab = Send Tab to Device
.accesskey = n
# Variables:
# $tabTitle (string) - Title of the tab to which the context menu is associated

Просмотреть файл

@ -70,3 +70,27 @@ button:focus-visible {
button:disabled {
opacity: 0.4;
}
.submenu-container {
display: flex;
flex-direction: row;
}
.submenu-icon {
display: inline-block;
background-image: url("chrome://global/skin/icons/arrow-right.svg");
background-position: center center;
background-repeat: no-repeat;
fill: currentColor;
width: var(--size-item-small);
height: var(--size-item-small);
flex: 1 1 auto;
&:dir(rtl) {
background-image: url("chrome://global/skin/icons/arrow-left.svg");
}
}
.submenu-label {
flex: 90% 1 0;
}

Просмотреть файл

@ -23,10 +23,13 @@
white-space: nowrap;
cursor: default;
overflow-y: auto;
max-height: 100%;
box-sizing: border-box;
}
:host(:not([slot=submenu])) {
max-height: 100%;
}
:host([stay-open]) {
position: initial;
display: inline-block;

Просмотреть файл

@ -63,6 +63,14 @@
this.toggleAttribute("open", val);
}
get stayOpen() {
return this.hasAttribute("stay-open");
}
set stayOpen(val) {
this.toggleAttribute("stay-open", val);
}
getTargetForEvent(event) {
if (!event) {
return null;
@ -77,13 +85,17 @@
return event._savedComposedTarget || event.target;
}
show(triggeringEvent) {
show(triggeringEvent, target) {
this.triggeringEvent = triggeringEvent;
this.lastAnchorNode = this.getTargetForEvent(this.triggeringEvent);
this.lastAnchorNode =
target || this.getTargetForEvent(this.triggeringEvent);
this.wasOpenedByKeyboard =
triggeringEvent &&
(triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD ||
triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_UNKNOWN);
triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_UNKNOWN ||
triggeringEvent.code == "ArrowRight" ||
triggeringEvent.code == "ArrowLeft");
this.open = true;
if (this.parentIsXULPanel()) {
@ -112,7 +124,7 @@
}
}
hide(triggeringEvent, { force = false } = {}) {
hide(triggeringEvent, { force = false } = {}, eventTarget) {
// It's possible this is being used in an unprivileged context, in which
// case it won't have access to Services / Services will be undeclared.
const autohideDisabled = this.hasServices()
@ -136,18 +148,18 @@
panel.hidePopup();
}
let target = this.getTargetForEvent(openingEvent);
let target = eventTarget || this.getTargetForEvent(openingEvent);
// Refocus the button that opened the menu if we have one.
if (target && this.wasOpenedByKeyboard) {
target.focus();
}
}
toggle(triggeringEvent) {
toggle(triggeringEvent, target = null) {
if (this.open) {
this.hide(triggeringEvent, { force: true });
this.hide(triggeringEvent, { force: true }, target);
} else {
this.show(triggeringEvent);
this.show(triggeringEvent, target);
}
}
@ -276,12 +288,15 @@
}
addHideListeners() {
if (this.hasAttribute("stay-open")) {
if (this.hasAttribute("stay-open") && !this.lastAnchorNode.hasSubmenu) {
// This is intended for inspection in Storybook.
return;
}
// Hide when a panel-item is clicked in the list.
this.addEventListener("click", this);
// Allows submenus to stopPropagation when focus is already in the menu
this.addEventListener("keydown", this);
// We need Escape/Tab/ArrowDown to work when opened with the mouse.
document.addEventListener("keydown", this);
// Hide when a click is initiated outside the panel.
document.addEventListener("mousedown", this);
@ -300,6 +315,7 @@
removeHideListeners() {
this.removeEventListener("click", this);
this.removeEventListener("keydown", this);
document.removeEventListener("keydown", this);
document.removeEventListener("mousedown", this);
document.removeEventListener("focusin", this);
@ -357,19 +373,15 @@
// Don't scroll the page or let the regular tab order take effect.
e.preventDefault();
// Prevents the host panel list from responding to these events while
// the submenu is active.
e.stopPropagation();
// Keep moving to the next/previous element sibling until we find a
// panel-item that isn't hidden.
let moveForward =
e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey);
// If the menu is opened with the mouse, the active element might be
// somewhere else in the document. In that case we should ignore it
// to avoid walking unrelated DOM nodes.
this.focusWalker.currentNode = this.contains(
this.getRootNode().activeElement
)
? this.getRootNode().activeElement
: this;
let nextItem = moveForward
? this.focusWalker.nextNode()
: this.focusWalker.previousNode();
@ -458,18 +470,70 @@
}
return this._focusWalker;
}
async setSubmenuAlign() {
const hostElement =
this.lastAnchorNode.parentElement || this.getRootNode().host;
// The showing attribute allows layout of the panel while remaining hidden
// from the user until alignment is set.
this.setAttribute("showing", "true");
// Wait for a layout flush, then find the bounds.
let { anchorWidth, anchorTop, panelTop } = await new Promise(resolve => {
requestAnimationFrame(() => {
// It's possible this is being used in a context where windowUtils is
// not available. In that case, fallback to using the element.
let getBounds = el =>
window.windowUtils
? window.windowUtils.getBoundsWithoutFlushing(el)
: el.getBoundingClientRect();
let anchorBounds = getBounds(this.lastAnchorNode);
let panelBounds = getBounds(hostElement);
resolve({
anchorWidth: anchorBounds.width,
anchorTop: anchorBounds.top,
panelTop: panelBounds.top,
});
});
});
let align = hostElement.getAttribute("align");
if (align == "right") {
this.style.right = `${anchorWidth}px`;
this.style.left = "";
} else {
this.style.left = `${anchorWidth}px`;
this.style.right = "";
}
let topOffset =
anchorTop -
panelTop -
(parseFloat(window.getComputedStyle(this)?.paddingTop) || 0);
this.style.top = `${topOffset}px`;
this.removeAttribute("showing");
}
async onShow() {
this.sendEvent("showing");
this.addHideListeners();
await this.setAlign();
if (this.lastAnchorNode?.hasSubmenu) {
await this.setSubmenuAlign();
} else {
await this.setAlign();
}
// Always reset this regardless of how the panel list is opened
// so the first child will be focusable.
this.focusWalker.currentNode = this;
// Wait until the next paint for the alignment to be set and panel to be
// visible.
requestAnimationFrame(() => {
if (this.wasOpenedByKeyboard) {
// Focus the first focusable panel-item if opened by keyboard.
this.focusWalker.currentNode = this;
this.focusWalker.nextNode();
}
@ -514,11 +578,11 @@
this.button = document.createElement("button");
this.button.setAttribute("role", "menuitem");
this.button.setAttribute("part", "button");
// Use a XUL label element if possible to show the accesskey.
this.label = document.createXULElement
? document.createXULElement("label")
: document.createElement("span");
this.button.appendChild(this.label);
let supportLinkSlot = document.createElement("slot");
@ -527,15 +591,39 @@
this.#defaultSlot = document.createElement("slot");
this.#defaultSlot.style.display = "none";
this.shadowRoot.append(
style,
this.button,
supportLinkSlot,
this.#defaultSlot
);
if (this.hasSubmenu) {
this.icon = document.createElement("div");
this.icon.setAttribute("class", "submenu-icon");
this.label.setAttribute("class", "submenu-label");
this.button.setAttribute("class", "submenu-container");
this.button.appendChild(this.icon);
this.submenuSlot = document.createElement("slot");
this.submenuSlot.name = "submenu";
this.shadowRoot.append(
style,
this.button,
this.#defaultSlot,
this.submenuSlot
);
} else {
this.shadowRoot.append(
style,
this.button,
supportLinkSlot,
this.#defaultSlot
);
}
}
connectedCallback() {
if (!this._l10nRootConnected && document.l10n) {
document.l10n.connectRoot(this.shadowRoot);
this._l10nRootConnected = true;
}
if (!this.#initialized) {
this.#initialized = true;
// When click listeners are added to the panel-item it creates a node in
@ -553,22 +641,48 @@
childList: true,
subtree: true,
});
if (this.hasSubmenu) {
this.setSubmenuContents();
}
}
this.panel = this.closest("panel-list");
this.panel =
this.getRootNode()?.host?.closest("panel-list") ||
this.closest("panel-list");
if (this.panel) {
this.panel.addEventListener("hidden", this);
this.panel.addEventListener("shown", this);
}
if (this.hasSubmenu) {
this.addEventListener("mouseenter", this);
this.addEventListener("mouseleave", this);
this.addEventListener("keydown", this);
}
}
disconnectedCallback() {
if (this._l10nRootConnected) {
document.l10n.disconnectRoot(this.shadowRoot);
this._l10nRootConnected = false;
}
if (this.panel) {
this.panel.removeEventListener("hidden", this);
this.panel.removeEventListener("shown", this);
this.panel = null;
}
if (this.hasSubmenu) {
this.removeEventListener("mouseenter", this);
this.removeEventListener("mouseleave", this);
this.removeEventListener("keydown", this);
}
}
get hasSubmenu() {
return this.hasAttribute("submenu");
}
attributeChangedCallback(name, oldVal, newVal) {
@ -606,6 +720,11 @@
.join("");
}
setSubmenuContents() {
this.submenuPanel = this.submenuSlot.assignedNodes()[0];
this.shadowRoot.append(this.submenuPanel);
}
get disabled() {
return this.button.hasAttribute("disabled");
}
@ -626,6 +745,17 @@
this.button.focus();
}
setArrowKeyRTL() {
let arrowOpenKey = "ArrowRight";
let arrowCloseKey = "ArrowLeft";
if (this.submenuPanel.isDocumentRTL()) {
arrowOpenKey = "ArrowLeft";
arrowCloseKey = "ArrowRight";
}
return [arrowOpenKey, arrowCloseKey];
}
handleEvent(e) {
// Bug 1588156 - Accesskey is not ignored for hidden non-input elements.
// Since the accesskey won't be ignored, we need to remove it ourselves
@ -644,6 +774,21 @@
this.accessKey = "";
}
break;
case "mouseenter":
case "mouseleave":
this.submenuPanel.toggle(e);
break;
case "keydown":
let [arrowOpenKey, arrowCloseKey] = this.setArrowKeyRTL();
if (e.key === arrowOpenKey) {
this.submenuPanel.show(e, e.target);
e.stopPropagation();
}
if (e.key === arrowCloseKey) {
this.submenuPanel.hide(e, { force: true }, e.target);
e.stopPropagation();
}
break;
}
}
}