diff options
Diffstat (limited to 'browser/components/customizableui')
174 files changed, 31489 insertions, 0 deletions
diff --git a/browser/components/customizableui/CustomizableUI.sys.mjs b/browser/components/customizableui/CustomizableUI.sys.mjs new file mode 100644 index 0000000000..d748b93a92 --- /dev/null +++ b/browser/components/customizableui/CustomizableUI.sys.mjs @@ -0,0 +1,6285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { SearchWidgetTracker } from "resource:///modules/SearchWidgetTracker.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", + CustomizableWidgets: "resource:///modules/CustomizableWidgets.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () { + const kUrl = + "chrome://browser/locale/customizableui/customizableWidgets.properties"; + return Services.strings.createBundle(kUrl); +}); + +const kDefaultThemeID = "default-theme@mozilla.org"; + +const kSpecialWidgetPfx = "customizableui-special-"; + +const kPrefCustomizationState = "browser.uiCustomization.state"; +const kPrefCustomizationAutoAdd = "browser.uiCustomization.autoAdd"; +const kPrefCustomizationDebug = "browser.uiCustomization.debug"; +const kPrefDrawInTitlebar = "browser.tabs.inTitlebar"; +const kPrefUIDensity = "browser.uidensity"; +const kPrefAutoTouchMode = "browser.touchmode.auto"; +const kPrefAutoHideDownloadsButton = "browser.download.autohideButton"; +const kPrefProtonToolbarVersion = "browser.proton.toolbar.version"; +const kPrefHomeButtonUsed = "browser.engagement.home-button.has-used"; +const kPrefLibraryButtonUsed = "browser.engagement.library-button.has-used"; +const kPrefSidebarButtonUsed = "browser.engagement.sidebar-button.has-used"; + +const kExpectedWindowURL = AppConstants.BROWSER_CHROME_URL; + +var gDefaultTheme; +var gSelectedTheme; + +/** + * The keys are the handlers that are fired when the event type (the value) + * is fired on the subview. A widget that provides a subview has the option + * of providing onViewShowing and onViewHiding event handlers. + */ +const kSubviewEvents = ["ViewShowing", "ViewHiding"]; + +/** + * The current version. We can use this to auto-add new default widgets as necessary. + * (would be const but isn't because of testing purposes) + */ +var kVersion = 20; + +/** + * Buttons removed from built-ins by version they were removed. kVersion must be + * bumped any time a new id is added to this. Use the button id as key, and + * version the button is removed in as the value. e.g. "pocket-button": 5 + */ +var ObsoleteBuiltinButtons = { + "feed-button": 15, +}; + +/** + * gPalette is a map of every widget that CustomizableUI.sys.mjs knows about, keyed + * on their IDs. + */ +var gPalette = new Map(); + +/** + * gAreas maps area IDs to Sets of properties about those areas. An area is a + * place where a widget can be put. + */ +var gAreas = new Map(); + +/** + * gPlacements maps area IDs to Arrays of widget IDs, indicating that the widgets + * are placed within that area (either directly in the area node, or in the + * customizationTarget of the node). + */ +var gPlacements = new Map(); + +/** + * gFuturePlacements represent placements that will happen for areas that have + * not yet loaded (due to lazy-loading). This can occur when add-ons register + * widgets. + */ +var gFuturePlacements = new Map(); + +var gSupportedWidgetTypes = new Set([ + // A button that does a command. + "button", + + // A button that opens a view in a panel (or in a subview of the panel). + "view", + + // A combination of the above, which looks different depending on whether it's + // located in the toolbar or in the panel: When located in the toolbar, shown + // as a combined item of a button and a dropmarker button. The button triggers + // the command and the dropmarker button opens the view. When located in the + // panel, shown as one item which opens the view, and the button command + // cannot be triggered separately. + "button-and-view", + + // A custom widget that defines its own markup. + "custom", +]); + +/** + * gPanelsForWindow is a list of known panels in a window which we may need to close + * should command events fire which target them. + */ +var gPanelsForWindow = new WeakMap(); + +/** + * gSeenWidgets remembers which widgets the user has seen for the first time + * before. This way, if a new widget is created, and the user has not seen it + * before, it can be put in its default location. Otherwise, it remains in the + * palette. + */ +var gSeenWidgets = new Set(); + +/** + * gDirtyAreaCache is a set of area IDs for areas where items have been added, + * moved or removed at least once. This set is persisted, and is used to + * optimize building of toolbars in the default case where no toolbars should + * be "dirty". + */ +var gDirtyAreaCache = new Set(); + +/** + * gPendingBuildAreas is a map from area IDs to map from build nodes to their + * existing children at the time of node registration, that are waiting + * for the area to be registered + */ +var gPendingBuildAreas = new Map(); + +var gSavedState = null; +var gRestoring = false; +var gDirty = false; +var gInBatchStack = 0; +var gResetting = false; +var gUndoResetting = false; + +/** + * gBuildAreas maps area IDs to actual area nodes within browser windows. + */ +var gBuildAreas = new Map(); + +/** + * gBuildWindows is a map of windows that have registered build areas, mapped + * to a Set of known toolboxes in that window. + */ +var gBuildWindows = new Map(); + +var gNewElementCount = 0; +var gGroupWrapperCache = new Map(); +var gSingleWrapperCache = new WeakMap(); +var gListeners = new Set(); + +var gUIStateBeforeReset = { + uiCustomizationState: null, + drawInTitlebar: null, + currentTheme: null, + uiDensity: null, + autoTouchMode: null, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gDebuggingEnabled", + kPrefCustomizationDebug, + false, + (pref, oldVal, newVal) => { + if (typeof lazy.log != "undefined") { + lazy.log.maxLogLevel = newVal ? "all" : "log"; + } + } +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "resetPBMToolbarButtonEnabled", + "browser.privatebrowsing.resetPBM.enabled", + false +); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevel: lazy.gDebuggingEnabled ? "all" : "log", + prefix: "CustomizableUI", + }; + return new ConsoleAPI(consoleOptions); +}); + +var CustomizableUIInternal = { + initialize() { + lazy.log.debug("Initializing"); + + lazy.AddonManagerPrivate.databaseReady.then(async () => { + lazy.AddonManager.addAddonListener(this); + + let addons = await lazy.AddonManager.getAddonsByTypes(["theme"]); + gDefaultTheme = addons.find(addon => addon.id == kDefaultThemeID); + gSelectedTheme = addons.find(addon => addon.isActive) || gDefaultTheme; + }); + + this.addListener(this); + this._defineBuiltInWidgets(); + this.loadSavedState(); + this._updateForNewVersion(); + this._updateForNewProtonVersion(); + this._markObsoleteBuiltinButtonsSeen(); + + this.registerArea( + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + { + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + anchor: "nav-bar-overflow-button", + }, + true + ); + + this.registerArea( + CustomizableUI.AREA_ADDONS, + { + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + anchor: "unified-extensions-button", + }, + false + ); + + let navbarPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + Services.policies.isAllowed("removeHomeButtonByDefault") + ? null + : "home-button", + "spring", + "urlbar-container", + "spring", + "save-to-pocket-button", + "downloads-button", + AppConstants.MOZ_DEV_EDITION ? "developer-button" : null, + "fxa-toolbar-menu-button", + lazy.resetPBMToolbarButtonEnabled ? "reset-pbm-toolbar-button" : null, + ].filter(name => name); + + this.registerArea( + CustomizableUI.AREA_NAVBAR, + { + type: CustomizableUI.TYPE_TOOLBAR, + overflowable: true, + defaultPlacements: navbarPlacements, + defaultCollapsed: false, + }, + true + ); + + if (AppConstants.MENUBAR_CAN_AUTOHIDE) { + this.registerArea( + CustomizableUI.AREA_MENUBAR, + { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: ["menubar-items"], + defaultCollapsed: true, + }, + true + ); + } + + this.registerArea( + CustomizableUI.AREA_TABSTRIP, + { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + ], + defaultCollapsed: null, + }, + true + ); + this.registerArea( + CustomizableUI.AREA_BOOKMARKS, + { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: ["personal-bookmarks"], + defaultCollapsed: "newtab", + }, + true + ); + + SearchWidgetTracker.init(); + + Services.obs.addObserver(this, "browser-set-toolbar-visibility"); + }, + + onEnabled(addon) { + if (addon.type == "theme") { + gSelectedTheme = addon; + } + }, + + get _builtinAreas() { + return new Set([ + ...this._builtinToolbars, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + CustomizableUI.AREA_ADDONS, + ]); + }, + + get _builtinToolbars() { + let toolbars = new Set([ + CustomizableUI.AREA_NAVBAR, + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_TABSTRIP, + ]); + if (AppConstants.platform != "macosx") { + toolbars.add(CustomizableUI.AREA_MENUBAR); + } + return toolbars; + }, + + _defineBuiltInWidgets() { + for (let widgetDefinition of lazy.CustomizableWidgets) { + this.createBuiltinWidget(widgetDefinition); + } + }, + + // eslint-disable-next-line complexity + _updateForNewVersion() { + // We should still enter even if gSavedState.currentVersion >= kVersion + // because the per-widget pref facility is independent of versioning. + if (!gSavedState) { + // Flip all the prefs so we don't try to re-introduce later: + for (let [, widget] of gPalette) { + if (widget.defaultArea && widget._introducedInVersion === "pref") { + let prefId = "browser.toolbarbuttons.introduced." + widget.id; + Services.prefs.setBoolPref(prefId, true); + } + } + return; + } + + let currentVersion = gSavedState.currentVersion; + for (let [id, widget] of gPalette) { + if (widget.defaultArea) { + let shouldAdd = false; + let shouldSetPref = false; + let prefId = "browser.toolbarbuttons.introduced." + widget.id; + if (widget._introducedInVersion === "pref") { + try { + shouldAdd = !Services.prefs.getBoolPref(prefId); + } catch (ex) { + // Pref doesn't exist: + shouldAdd = true; + } + shouldSetPref = shouldAdd; + } else if (widget._introducedInVersion > currentVersion) { + shouldAdd = true; + } + + if (shouldAdd) { + let futurePlacements = gFuturePlacements.get(widget.defaultArea); + if (futurePlacements) { + futurePlacements.add(id); + } else { + gFuturePlacements.set(widget.defaultArea, new Set([id])); + } + if (shouldSetPref) { + Services.prefs.setBoolPref(prefId, true); + } + } + } + } + + // Nothing to migrate now if we don't have placements. + if (!gSavedState.placements) { + return; + } + + if ( + currentVersion < 7 && + gSavedState.placements[CustomizableUI.AREA_NAVBAR] + ) { + let placements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + let newPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + "home-button", + ]; + for (let button of placements) { + if (!newPlacements.includes(button)) { + newPlacements.push(button); + } + } + + if (!newPlacements.includes("sidebar-button")) { + newPlacements.push("sidebar-button"); + } + + gSavedState.placements[CustomizableUI.AREA_NAVBAR] = newPlacements; + } + + if (currentVersion < 8 && gSavedState.placements["PanelUI-contents"]) { + let savedPanelPlacements = gSavedState.placements["PanelUI-contents"]; + delete gSavedState.placements["PanelUI-contents"]; + let defaultPlacements = [ + "edit-controls", + "zoom-controls", + "new-window-button", + "privatebrowsing-button", + "save-page-button", + "print-button", + "history-panelmenu", + "fullscreen-button", + "find-button", + "preferences-button", + // This widget no longer exists as of 2023, see Bug 1799009. + "add-ons-button", + "sync-button", + ]; + + if (!AppConstants.MOZ_DEV_EDITION) { + defaultPlacements.splice(-1, 0, "developer-button"); + } + + let showCharacterEncoding = Services.prefs.getComplexValue( + "browser.menu.showCharacterEncoding", + Ci.nsIPrefLocalizedString + ).data; + if (showCharacterEncoding == "true") { + defaultPlacements.push("characterencoding-button"); + } + + savedPanelPlacements = savedPanelPlacements.filter( + id => !defaultPlacements.includes(id) + ); + + if (savedPanelPlacements.length) { + gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = + savedPanelPlacements; + } + } + + if (currentVersion < 9 && gSavedState.placements["nav-bar"]) { + let placements = gSavedState.placements["nav-bar"]; + if (placements.includes("urlbar-container")) { + let urlbarIndex = placements.indexOf("urlbar-container"); + let secondSpringIndex = urlbarIndex + 1; + // Insert if there isn't already a spring before the urlbar + if ( + urlbarIndex == 0 || + !placements[urlbarIndex - 1].startsWith(kSpecialWidgetPfx + "spring") + ) { + placements.splice(urlbarIndex, 0, "spring"); + // The url bar is now 1 index later, so increment the insertion point for + // the second spring. + secondSpringIndex++; + } + // If the search container is present, insert after the search container + // instead of after the url bar + let searchContainerIndex = placements.indexOf("search-container"); + if (searchContainerIndex != -1) { + secondSpringIndex = searchContainerIndex + 1; + } + if ( + secondSpringIndex == placements.length || + !placements[secondSpringIndex].startsWith( + kSpecialWidgetPfx + "spring" + ) + ) { + placements.splice(secondSpringIndex, 0, "spring"); + } + } + + // Finally, replace the bookmarks menu button with the library one if present + if (placements.includes("bookmarks-menu-button")) { + let bmbIndex = placements.indexOf("bookmarks-menu-button"); + placements.splice(bmbIndex, 1); + let downloadButtonIndex = placements.indexOf("downloads-button"); + let libraryIndex = + downloadButtonIndex == -1 ? bmbIndex : downloadButtonIndex + 1; + placements.splice(libraryIndex, 0, "library-button"); + } + } + + if (currentVersion < 10) { + for (let placements of Object.values(gSavedState.placements)) { + if (placements.includes("webcompat-reporter-button")) { + placements.splice(placements.indexOf("webcompat-reporter-button"), 1); + break; + } + } + } + + // Move the downloads button to the default position in the navbar if it's + // not there already. + if (currentVersion < 11) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + // First remove from wherever it currently lives, if anywhere: + for (let placements of Object.values(gSavedState.placements)) { + let existingIndex = placements.indexOf("downloads-button"); + if (existingIndex != -1) { + placements.splice(existingIndex, 1); + break; // It can only be in 1 place, so no point looking elsewhere. + } + } + + // Now put the button in the navbar in the correct spot: + if (navbarPlacements) { + let insertionPoint = navbarPlacements.indexOf("urlbar-container"); + // Deliberately iterate to 1 past the end of the array to insert at the + // end if need be. + while (++insertionPoint < navbarPlacements.length) { + let widget = navbarPlacements[insertionPoint]; + // If we find a non-searchbar, non-spacer node, break out of the loop: + if ( + widget != "search-container" && + !this.matchingSpecials(widget, "spring") + ) { + break; + } + } + // We either found the right spot, or reached the end of the + // placements, so insert here: + navbarPlacements.splice(insertionPoint, 0, "downloads-button"); + } + } + + if (currentVersion < 12) { + const removedButtons = [ + "loop-call-button", + "loop-button-throttled", + "pocket-button", + ]; + for (let placements of Object.values(gSavedState.placements)) { + for (let button of removedButtons) { + let buttonIndex = placements.indexOf(button); + if (buttonIndex != -1) { + placements.splice(buttonIndex, 1); + } + } + } + } + + // Remove the old placements from the now-gone Nightly-only + // "New non-e10s window" button. + if (currentVersion < 13) { + for (let placements of Object.values(gSavedState.placements)) { + let buttonIndex = placements.indexOf("e10s-button"); + if (buttonIndex != -1) { + placements.splice(buttonIndex, 1); + } + } + } + + // Remove unsupported custom toolbar saved placements + if (currentVersion < 14) { + for (let area in gSavedState.placements) { + if (!this._builtinAreas.has(area)) { + delete gSavedState.placements[area]; + } + } + } + + // Add the FxA toolbar menu as the right most button item + if (currentVersion < 16) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + // Place the menu item as the first item to the left of the hamburger menu + if (navbarPlacements) { + navbarPlacements.push("fxa-toolbar-menu-button"); + } + } + + // Add the save to Pocket button left of downloads button. + if (currentVersion < 17) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + let persistedPageActionsPref = Services.prefs.getCharPref( + "browser.pageActions.persistedActions", + "" + ); + let pocketPreviouslyInUrl = true; + try { + let persistedPageActionsData = JSON.parse(persistedPageActionsPref); + // If Pocket was previously not in the url bar, let's not put it in the toolbar. + // It'll still be an option to add from the customization page. + pocketPreviouslyInUrl = + persistedPageActionsData.idsInUrlbar.includes("pocket"); + } catch (e) {} + if (navbarPlacements && pocketPreviouslyInUrl) { + // Pocket's new home is next to the downloads button, or the next best spot. + let newPosition = + navbarPlacements.indexOf("downloads-button") ?? + navbarPlacements.indexOf("fxa-toolbar-menu-button") ?? + navbarPlacements.length; + + navbarPlacements.splice(newPosition, 0, "save-to-pocket-button"); + } + } + + // Add firefox-view if not present + if (currentVersion < 18) { + let tabstripPlacements = + gSavedState.placements[CustomizableUI.AREA_TABSTRIP]; + if ( + tabstripPlacements && + !tabstripPlacements.includes("firefox-view-button") + ) { + tabstripPlacements.unshift("firefox-view-button"); + } + } + + // Unified Extensions addon button migration, which puts any browser action + // buttons in the overflow menu into the addons panel instead. + if (currentVersion < 19) { + let overflowPlacements = + gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] || []; + // The most likely case is that there are no AREA_ADDONS placements, in which case the + // array won't exist. + let addonsPlacements = + gSavedState.placements[CustomizableUI.AREA_ADDONS] || []; + + // Migration algorithm for transitioning to Unified Extensions: + // + // 1. Create two arrays, one for extension widgets, one for built-in widgets. + // 2. Iterate all items in the overflow panel, and push them into the + // appropriate array based on whether or not its an extension widget. + // 3. Overwrite the overflow panel placements with the built-in widgets array. + // 4. Prepend the extension widgets to the addonsPlacements array. Note that this + // does not overwrite this array as a precaution because it's possible + // (though pretty unlikely) that some widgets are already there. + // + // For extension widgets that were in the palette, they will be appended to the + // addons area when they're created within createWidget. + let extWidgets = []; + let builtInWidgets = []; + for (let widgetId of overflowPlacements) { + if (CustomizableUI.isWebExtensionWidget(widgetId)) { + extWidgets.push(widgetId); + } else { + builtInWidgets.push(widgetId); + } + } + gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = + builtInWidgets; + gSavedState.placements[CustomizableUI.AREA_ADDONS] = [ + ...extWidgets, + ...addonsPlacements, + ]; + } + + // Add the PBM reset button as the right most button item + if (currentVersion < 20) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + // Place the button as the first item to the left of the hamburger menu + if ( + navbarPlacements && + !navbarPlacements.includes("reset-pbm-toolbar-button") + ) { + navbarPlacements.push("reset-pbm-toolbar-button"); + } + } + }, + + _updateForNewProtonVersion() { + const VERSION = 3; + let currentVersion = Services.prefs.getIntPref( + kPrefProtonToolbarVersion, + 0 + ); + if (currentVersion >= VERSION) { + return; + } + + let placements = gSavedState?.placements?.[CustomizableUI.AREA_NAVBAR]; + + if (!placements) { + // The profile was created with this version, so no need to migrate. + Services.prefs.setIntPref(kPrefProtonToolbarVersion, VERSION); + return; + } + + // Remove the home button if it hasn't been used and is set to about:home + if (currentVersion < 1) { + let homePage = lazy.HomePage.get(); + if ( + placements.includes("home-button") && + !Services.prefs.getBoolPref(kPrefHomeButtonUsed) && + (homePage == "about:home" || homePage == "about:blank") && + Services.policies.isAllowed("removeHomeButtonByDefault") + ) { + placements.splice(placements.indexOf("home-button"), 1); + } + } + + // Remove the library button if it hasn't been used + if (currentVersion < 2) { + if ( + placements.includes("library-button") && + !Services.prefs.getBoolPref(kPrefLibraryButtonUsed) + ) { + placements.splice(placements.indexOf("library-button"), 1); + } + } + + // Remove the library button if it hasn't been used + if (currentVersion < 3) { + if ( + placements.includes("sidebar-button") && + !Services.prefs.getBoolPref(kPrefSidebarButtonUsed) + ) { + placements.splice(placements.indexOf("sidebar-button"), 1); + } + } + + Services.prefs.setIntPref(kPrefProtonToolbarVersion, VERSION); + }, + + /** + * _markObsoleteBuiltinButtonsSeen + * when upgrading, ensure obsoleted buttons are in seen state. + */ + _markObsoleteBuiltinButtonsSeen() { + if (!gSavedState) { + return; + } + let currentVersion = gSavedState.currentVersion; + if (currentVersion >= kVersion) { + return; + } + // we're upgrading, update state if necessary + for (let id in ObsoleteBuiltinButtons) { + let version = ObsoleteBuiltinButtons[id]; + if (version == kVersion) { + gSeenWidgets.add(id); + gDirty = true; + } + } + }, + + _placeNewDefaultWidgetsInArea(aArea) { + let futurePlacedWidgets = gFuturePlacements.get(aArea); + let savedPlacements = + gSavedState && gSavedState.placements && gSavedState.placements[aArea]; + let defaultPlacements = gAreas.get(aArea).get("defaultPlacements"); + if ( + !savedPlacements || + !savedPlacements.length || + !futurePlacedWidgets || + !defaultPlacements || + !defaultPlacements.length + ) { + return; + } + let defaultWidgetIndex = -1; + + for (let widgetId of futurePlacedWidgets) { + let widget = gPalette.get(widgetId); + if ( + !widget || + widget.source !== CustomizableUI.SOURCE_BUILTIN || + !widget.defaultArea || + !widget._introducedInVersion || + savedPlacements.includes(widget.id) + ) { + continue; + } + defaultWidgetIndex = defaultPlacements.indexOf(widget.id); + if (defaultWidgetIndex === -1) { + continue; + } + // Now we know that this widget should be here by default, was newly introduced, + // and we have a saved state to insert into, and a default state to work off of. + // Try introducing after widgets that come before it in the default placements: + for (let i = defaultWidgetIndex; i >= 0; i--) { + // Special case: if the defaults list this widget as coming first, insert at the beginning: + if (i === 0 && i === defaultWidgetIndex) { + savedPlacements.splice(0, 0, widget.id); + // Before you ask, yes, deleting things inside a let x of y loop where y is a Set is + // safe, and we won't skip any items. + futurePlacedWidgets.delete(widget.id); + gDirty = true; + break; + } + // Otherwise, if we're somewhere other than the beginning, check if the previous + // widget is in the saved placements. + if (i) { + let previousWidget = defaultPlacements[i - 1]; + let previousWidgetIndex = savedPlacements.indexOf(previousWidget); + if (previousWidgetIndex != -1) { + savedPlacements.splice(previousWidgetIndex + 1, 0, widget.id); + futurePlacedWidgets.delete(widget.id); + gDirty = true; + break; + } + } + } + // The loop above either inserts the item or doesn't - either way, we can get away + // with doing nothing else now; if the item remains in gFuturePlacements, we'll + // add it at the end in restoreStateForArea. + } + this.saveState(); + }, + + getCustomizationTarget(aElement) { + if (!aElement) { + return null; + } + + if ( + !aElement._customizationTarget && + aElement.hasAttribute("customizable") + ) { + let id = aElement.getAttribute("customizationtarget"); + if (id) { + aElement._customizationTarget = + aElement.ownerDocument.getElementById(id); + } + + if (!aElement._customizationTarget) { + aElement._customizationTarget = aElement; + } + } + + return aElement._customizationTarget; + }, + + wrapWidget(aWidgetId) { + if (gGroupWrapperCache.has(aWidgetId)) { + return gGroupWrapperCache.get(aWidgetId); + } + + let provider = this.getWidgetProvider(aWidgetId); + if (!provider) { + return null; + } + + if (provider == CustomizableUI.PROVIDER_API) { + let widget = gPalette.get(aWidgetId); + if (!widget.wrapper) { + widget.wrapper = new WidgetGroupWrapper(widget); + gGroupWrapperCache.set(aWidgetId, widget.wrapper); + } + return widget.wrapper; + } + + // PROVIDER_SPECIAL gets treated the same as PROVIDER_XUL. + // XXXgijs: this causes bugs in code that depends on widgetWrapper.provider + // giving an accurate answer... filed as bug 1379821 + let wrapper = new XULWidgetGroupWrapper(aWidgetId); + gGroupWrapperCache.set(aWidgetId, wrapper); + return wrapper; + }, + + registerArea(aName, aProperties, aInternalCaller) { + if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) { + throw new Error("Invalid area name"); + } + + let areaIsKnown = gAreas.has(aName); + let props = areaIsKnown ? gAreas.get(aName) : new Map(); + const kImmutableProperties = new Set(["type", "overflowable"]); + for (let key in aProperties) { + if ( + areaIsKnown && + kImmutableProperties.has(key) && + props.get(key) != aProperties[key] + ) { + throw new Error("An area cannot change the property for '" + key + "'"); + } + props.set(key, aProperties[key]); + } + // Default to a toolbar: + if (!props.has("type")) { + props.set("type", CustomizableUI.TYPE_TOOLBAR); + } + if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { + // Check aProperties instead of props because this check is only interested + // in the passed arguments, not the state of a potentially pre-existing area. + if (!aInternalCaller && aProperties.defaultCollapsed) { + throw new Error( + "defaultCollapsed is only allowed for default toolbars." + ); + } + if (!props.has("defaultCollapsed")) { + props.set("defaultCollapsed", true); + } + } else if (props.has("defaultCollapsed")) { + throw new Error("defaultCollapsed only applies for TYPE_TOOLBAR areas."); + } + // Sanity check type: + let allTypes = [CustomizableUI.TYPE_TOOLBAR, CustomizableUI.TYPE_PANEL]; + if (!allTypes.includes(props.get("type"))) { + throw new Error("Invalid area type " + props.get("type")); + } + + // And to no placements: + if (!props.has("defaultPlacements")) { + props.set("defaultPlacements", []); + } + // Sanity check default placements array: + if (!Array.isArray(props.get("defaultPlacements"))) { + throw new Error("Should provide an array of default placements"); + } + + if (!areaIsKnown) { + gAreas.set(aName, props); + + // Reconcile new default widgets. Have to do this before we start restoring things. + this._placeNewDefaultWidgetsInArea(aName); + + if ( + props.get("type") == CustomizableUI.TYPE_TOOLBAR && + !gPlacements.has(aName) + ) { + // Guarantee this area exists in gFuturePlacements, to avoid checking it in + // various places elsewhere. + if (!gFuturePlacements.has(aName)) { + gFuturePlacements.set(aName, new Set()); + } + } else { + this.restoreStateForArea(aName); + } + + // If we have pending build area nodes, register all of them + if (gPendingBuildAreas.has(aName)) { + let pendingNodes = gPendingBuildAreas.get(aName); + for (let pendingNode of pendingNodes) { + this.registerToolbarNode(pendingNode); + } + gPendingBuildAreas.delete(aName); + } + } + }, + + unregisterArea(aName, aDestroyPlacements) { + if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) { + throw new Error("Invalid area name"); + } + if (!gAreas.has(aName) && !gPlacements.has(aName)) { + throw new Error("Area not registered"); + } + + // Move all the widgets out + this.beginBatchUpdate(); + try { + let placements = gPlacements.get(aName); + if (placements) { + // Need to clone this array so removeWidgetFromArea doesn't modify it + placements = [...placements]; + placements.forEach(this.removeWidgetFromArea, this); + } + + // Delete all remaining traces. + gAreas.delete(aName); + // Only destroy placements when necessary: + if (aDestroyPlacements) { + gPlacements.delete(aName); + } else { + // Otherwise we need to re-set them, as removeFromArea will have emptied + // them out: + gPlacements.set(aName, placements); + } + gFuturePlacements.delete(aName); + let existingAreaNodes = gBuildAreas.get(aName); + if (existingAreaNodes) { + for (let areaNode of existingAreaNodes) { + this.notifyListeners( + "onAreaNodeUnregistered", + aName, + this.getCustomizationTarget(areaNode), + CustomizableUI.REASON_AREA_UNREGISTERED + ); + } + } + gBuildAreas.delete(aName); + } finally { + this.endBatchUpdate(true); + } + }, + + registerToolbarNode(aToolbar) { + let area = aToolbar.id; + if (gBuildAreas.has(area) && gBuildAreas.get(area).has(aToolbar)) { + return; + } + let areaProperties = gAreas.get(area); + + // If this area is not registered, try to do it automatically: + if (!areaProperties) { + if (!gPendingBuildAreas.has(area)) { + gPendingBuildAreas.set(area, []); + } + gPendingBuildAreas.get(area).push(aToolbar); + return; + } + + this.beginBatchUpdate(); + try { + let placements = gPlacements.get(area); + if ( + !placements && + areaProperties.get("type") == CustomizableUI.TYPE_TOOLBAR + ) { + this.restoreStateForArea(area); + placements = gPlacements.get(area); + } + + // For toolbars that need it, mark as dirty. + let defaultPlacements = areaProperties.get("defaultPlacements"); + if ( + !this._builtinToolbars.has(area) || + placements.length != defaultPlacements.length || + !placements.every((id, i) => id == defaultPlacements[i]) + ) { + gDirtyAreaCache.add(area); + } + + if (areaProperties.get("overflowable")) { + aToolbar.overflowable = new OverflowableToolbar(aToolbar); + } + + this.registerBuildArea(area, aToolbar); + + // We only build the toolbar if it's been marked as "dirty". Dirty means + // one of the following things: + // 1) Items have been added, moved or removed from this toolbar before. + // 2) The number of children of the toolbar does not match the length of + // the placements array for that area. + // + // This notion of being "dirty" is stored in a cache which is persisted + // in the saved state. + // + // Secondly, if the list of placements contains an API-provided widget, + // we need to call `buildArea` or it won't be built and put in the toolbar. + if ( + gDirtyAreaCache.has(area) || + placements.some(id => gPalette.has(id)) + ) { + this.buildArea(area, placements, aToolbar); + } else { + // We must have a builtin toolbar that's in the default state. We need + // to only make sure that all the special nodes are correct. + let specials = placements.filter(p => this.isSpecialWidget(p)); + if (specials.length) { + this.updateSpecialsForBuiltinToolbar(aToolbar, specials); + } + } + this.notifyListeners( + "onAreaNodeRegistered", + area, + this.getCustomizationTarget(aToolbar) + ); + } finally { + this.endBatchUpdate(); + } + }, + + updateSpecialsForBuiltinToolbar(aToolbar, aSpecialIDs) { + // Nodes are going to be in the correct order, so we can do this straightforwardly: + let { children } = this.getCustomizationTarget(aToolbar); + for (let kid of children) { + if ( + this.matchingSpecials(aSpecialIDs[0], kid) && + kid.getAttribute("skipintoolbarset") != "true" + ) { + kid.id = aSpecialIDs.shift(); + } + if (!aSpecialIDs.length) { + return; + } + } + }, + + buildArea(aArea, aPlacements, aAreaNode) { + let document = aAreaNode.ownerDocument; + let window = document.defaultView; + let inPrivateWindow = lazy.PrivateBrowsingUtils.isWindowPrivate(window); + let container = this.getCustomizationTarget(aAreaNode); + let areaIsPanel = + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL; + + if (!container) { + throw new Error( + "Expected area " + aArea + " to have a customizationTarget attribute." + ); + } + + // Restore nav-bar visibility since it may have been hidden + // through a migration path (bug 938980) or an add-on. + if (aArea == CustomizableUI.AREA_NAVBAR) { + aAreaNode.collapsed = false; + } + + this.beginBatchUpdate(); + + try { + let currentNode = container.firstElementChild; + let placementsToRemove = new Set(); + for (let id of aPlacements) { + while ( + currentNode && + currentNode.getAttribute("skipintoolbarset") == "true" + ) { + currentNode = currentNode.nextElementSibling; + } + + // Fix ids for specials and continue, for correctly placed specials. + if ( + currentNode && + (!currentNode.id || CustomizableUI.isSpecialWidget(currentNode)) && + this.matchingSpecials(id, currentNode) + ) { + currentNode.id = id; + } + if (currentNode && currentNode.id == id) { + currentNode = currentNode.nextElementSibling; + continue; + } + + if (this.isSpecialWidget(id) && areaIsPanel) { + placementsToRemove.add(id); + continue; + } + + let [provider, node] = this.getWidgetNode(id, window); + if (!node) { + lazy.log.debug("Unknown widget: " + id); + continue; + } + + let widget = null; + // If the placements have items in them which are (now) no longer removable, + // we shouldn't be moving them: + if (provider == CustomizableUI.PROVIDER_API) { + widget = gPalette.get(id); + if (!widget.removable && aArea != widget.defaultArea) { + placementsToRemove.add(id); + continue; + } + } else if ( + provider == CustomizableUI.PROVIDER_XUL && + node.parentNode != container && + !this.isWidgetRemovable(node) + ) { + placementsToRemove.add(id); + continue; + } // Special widgets are always removable, so no need to check them + + if (inPrivateWindow && widget && !widget.showInPrivateBrowsing) { + continue; + } + + this.ensureButtonContextMenu(node, aAreaNode); + + // This needs updating in case we're resetting / undoing a reset. + if (widget) { + widget.currentArea = aArea; + } + this.insertWidgetBefore(node, currentNode, container, aArea); + if (gResetting) { + this.notifyListeners("onWidgetReset", node, container); + } else if (gUndoResetting) { + this.notifyListeners("onWidgetUndoMove", node, container); + } + } + + if (currentNode) { + let palette = window.gNavToolbox ? window.gNavToolbox.palette : null; + let limit = currentNode.previousElementSibling; + let node = container.lastElementChild; + while (node && node != limit) { + let previousSibling = node.previousElementSibling; + // Nodes opt-in to removability. If they're removable, and we haven't + // seen them in the placements array, then we toss them into the palette + // if one exists. If no palette exists, we just remove the node. If the + // node is not removable, we leave it where it is. However, we can only + // safely touch elements that have an ID - both because we depend on + // IDs (or are specials), and because such elements are not intended to + // be widgets (eg, titlebar-spacer elements). + if ( + (node.id || this.isSpecialWidget(node)) && + node.getAttribute("skipintoolbarset") != "true" + ) { + if (this.isWidgetRemovable(node)) { + if (node.id && (gResetting || gUndoResetting)) { + let widget = gPalette.get(node.id); + if (widget) { + widget.currentArea = null; + } + } + this.notifyDOMChange(node, null, container, true, () => { + if (palette && !this.isSpecialWidget(node.id)) { + palette.appendChild(node); + this.removeLocationAttributes(node); + } else { + container.removeChild(node); + } + }); + } else { + node.setAttribute("removable", false); + lazy.log.debug( + "Adding non-removable widget to placements of " + + aArea + + ": " + + node.id + ); + gPlacements.get(aArea).push(node.id); + gDirty = true; + } + } + node = previousSibling; + } + } + + // If there are placements in here which aren't removable from their original area, + // we remove them from this area's placement array. They will (have) be(en) added + // to their original area's placements array in the block above this one. + if (placementsToRemove.size) { + let placementAry = gPlacements.get(aArea); + for (let id of placementsToRemove) { + let index = placementAry.indexOf(id); + placementAry.splice(index, 1); + } + } + + if (gResetting) { + this.notifyListeners("onAreaReset", aArea, container); + } + } finally { + this.endBatchUpdate(); + } + }, + + addPanelCloseListeners(aPanel) { + Services.els.addSystemEventListener(aPanel, "click", this, false); + Services.els.addSystemEventListener(aPanel, "keypress", this, false); + let win = aPanel.ownerGlobal; + if (!gPanelsForWindow.has(win)) { + gPanelsForWindow.set(win, new Set()); + } + gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel)); + }, + + removePanelCloseListeners(aPanel) { + Services.els.removeSystemEventListener(aPanel, "click", this, false); + Services.els.removeSystemEventListener(aPanel, "keypress", this, false); + let win = aPanel.ownerGlobal; + let panels = gPanelsForWindow.get(win); + if (panels) { + panels.delete(this._getPanelForNode(aPanel)); + } + }, + + ensureButtonContextMenu(aNode, aAreaNode, forcePanel) { + const kPanelItemContextMenu = "customizationPanelItemContextMenu"; + + let currentContextMenu = + aNode.getAttribute("context") || aNode.getAttribute("contextmenu"); + let contextMenuForPlace; + + if ( + CustomizableUI.isWebExtensionWidget(aNode.id) && + (aAreaNode?.id == CustomizableUI.AREA_ADDONS || + aNode.getAttribute("overflowedItem") == "true") + ) { + contextMenuForPlace = null; + } else { + contextMenuForPlace = + forcePanel || "panel" == CustomizableUI.getPlaceForItem(aAreaNode) + ? kPanelItemContextMenu + : null; + } + if (contextMenuForPlace && !currentContextMenu) { + aNode.setAttribute("context", contextMenuForPlace); + } else if ( + currentContextMenu == kPanelItemContextMenu && + contextMenuForPlace != kPanelItemContextMenu + ) { + aNode.removeAttribute("context"); + aNode.removeAttribute("contextmenu"); + } + }, + + getWidgetProvider(aWidgetId) { + if (this.isSpecialWidget(aWidgetId)) { + return CustomizableUI.PROVIDER_SPECIAL; + } + if (gPalette.has(aWidgetId)) { + return CustomizableUI.PROVIDER_API; + } + // If this was an API widget that was destroyed, return null: + if (gSeenWidgets.has(aWidgetId)) { + return null; + } + + // We fall back to the XUL provider, but we don't know for sure (at this + // point) whether it exists there either. So the API is technically lying. + // Ideally, it would be able to return an error value (or throw an + // exception) if it really didn't exist. Our code calling this function + // handles that fine, but this is a public API. + return CustomizableUI.PROVIDER_XUL; + }, + + getWidgetNode(aWidgetId, aWindow) { + let document = aWindow.document; + + if (this.isSpecialWidget(aWidgetId)) { + let widgetNode = + document.getElementById(aWidgetId) || + this.createSpecialWidget(aWidgetId, document); + return [CustomizableUI.PROVIDER_SPECIAL, widgetNode]; + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + // If we have an instance of this widget already, just use that. + if (widget.instances.has(document)) { + lazy.log.debug( + "An instance of widget " + + aWidgetId + + " already exists in this " + + "document. Reusing." + ); + return [CustomizableUI.PROVIDER_API, widget.instances.get(document)]; + } + + return [CustomizableUI.PROVIDER_API, this.buildWidget(document, widget)]; + } + + lazy.log.debug("Searching for " + aWidgetId + " in toolbox."); + let node = this.findWidgetInWindow(aWidgetId, aWindow); + if (node) { + return [CustomizableUI.PROVIDER_XUL, node]; + } + + lazy.log.debug("No node for " + aWidgetId + " found."); + return [null, null]; + }, + + registerPanelNode(aNode, aArea) { + if (gBuildAreas.has(aArea) && gBuildAreas.get(aArea).has(aNode)) { + return; + } + + aNode._customizationTarget = aNode; + this.addPanelCloseListeners(this._getPanelForNode(aNode)); + + let placements = gPlacements.get(aArea); + this.buildArea(aArea, placements, aNode); + this.notifyListeners("onAreaNodeRegistered", aArea, aNode); + + for (let child of aNode.children) { + if (child.localName != "toolbarbutton") { + if (child.localName == "toolbaritem") { + this.ensureButtonContextMenu(child, aNode, true); + } + continue; + } + this.ensureButtonContextMenu(child, aNode, true); + } + + this.registerBuildArea(aArea, aNode); + }, + + onWidgetAdded(aWidgetId, aArea, aPosition) { + this.insertNode(aWidgetId, aArea, aPosition, true); + + if (!gResetting) { + this._clearPreviousUIState(); + } + }, + + onWidgetRemoved(aWidgetId, aArea) { + let areaNodes = gBuildAreas.get(aArea); + if (!areaNodes) { + return; + } + + let area = gAreas.get(aArea); + let isToolbar = area.get("type") == CustomizableUI.TYPE_TOOLBAR; + let isOverflowable = isToolbar && area.get("overflowable"); + let showInPrivateBrowsing = gPalette.has(aWidgetId) + ? gPalette.get(aWidgetId).showInPrivateBrowsing + : true; + + for (let areaNode of areaNodes) { + let window = areaNode.ownerGlobal; + if ( + !showInPrivateBrowsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + continue; + } + + let container = this.getCustomizationTarget(areaNode); + let widgetNode = window.document.getElementById(aWidgetId); + if (widgetNode && isOverflowable) { + container = areaNode.overflowable.getContainerFor(widgetNode); + } + + if (!widgetNode || !container.contains(widgetNode)) { + lazy.log.info( + "Widget " + aWidgetId + " not found, unable to remove from " + aArea + ); + continue; + } + + this.notifyDOMChange(widgetNode, null, container, true, () => { + // We remove location attributes here to make sure they're gone too when a + // widget is removed from a toolbar to the palette. See bug 930950. + this.removeLocationAttributes(widgetNode); + // We also need to remove the panel context menu if it's there: + this.ensureButtonContextMenu(widgetNode); + if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { + container.removeChild(widgetNode); + } else { + window.gNavToolbox.palette.appendChild(widgetNode); + } + }); + + let windowCache = gSingleWrapperCache.get(window); + if (windowCache) { + windowCache.delete(aWidgetId); + } + } + if (!gResetting) { + this._clearPreviousUIState(); + } + }, + + onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) { + this.insertNode(aWidgetId, aArea, aNewPosition); + if (!gResetting) { + this._clearPreviousUIState(); + } + }, + + onCustomizeEnd(aWindow) { + this._clearPreviousUIState(); + }, + + registerBuildArea(aArea, aNode) { + // We ensure that the window is registered to have its customization data + // cleaned up when unloading. + let window = aNode.ownerGlobal; + if (window.closed) { + return; + } + this.registerBuildWindow(window); + + // Also register this build area's toolbox. + if (window.gNavToolbox) { + gBuildWindows.get(window).add(window.gNavToolbox); + } + + if (!gBuildAreas.has(aArea)) { + gBuildAreas.set(aArea, new Set()); + } + + gBuildAreas.get(aArea).add(aNode); + + // Give a class to all customize targets to be used for styling in Customize Mode + let customizableNode = this.getCustomizeTargetForArea(aArea, window); + customizableNode.classList.add("customization-target"); + }, + + registerBuildWindow(aWindow) { + if (!gBuildWindows.has(aWindow)) { + gBuildWindows.set(aWindow, new Set()); + + aWindow.addEventListener("unload", this); + aWindow.addEventListener("command", this, true); + + this.notifyListeners("onWindowOpened", aWindow); + } + }, + + unregisterBuildWindow(aWindow) { + aWindow.removeEventListener("unload", this); + aWindow.removeEventListener("command", this, true); + gPanelsForWindow.delete(aWindow); + gBuildWindows.delete(aWindow); + gSingleWrapperCache.delete(aWindow); + let document = aWindow.document; + + for (let [areaId, areaNodes] of gBuildAreas) { + let areaProperties = gAreas.get(areaId); + for (let node of areaNodes) { + if (node.ownerDocument == document) { + this.notifyListeners( + "onAreaNodeUnregistered", + areaId, + this.getCustomizationTarget(node), + CustomizableUI.REASON_WINDOW_CLOSED + ); + if (areaProperties.get("overflowable")) { + node.overflowable.uninit(); + node.overflowable = null; + } + areaNodes.delete(node); + } + } + } + + for (let [, widget] of gPalette) { + widget.instances.delete(document); + this.notifyListeners("onWidgetInstanceRemoved", widget.id, document); + } + + for (let [, pendingNodes] of gPendingBuildAreas) { + for (let i = pendingNodes.length - 1; i >= 0; i--) { + if (pendingNodes[i].ownerDocument == document) { + pendingNodes.splice(i, 1); + } + } + } + + this.notifyListeners("onWindowClosed", aWindow); + }, + + setLocationAttributes(aNode, aArea) { + let props = gAreas.get(aArea); + if (!props) { + throw new Error( + "Expected area " + + aArea + + " to have a properties Map " + + "associated with it." + ); + } + + aNode.setAttribute("cui-areatype", props.get("type") || ""); + let anchor = props.get("anchor"); + if (anchor) { + aNode.setAttribute("cui-anchorid", anchor); + } else { + aNode.removeAttribute("cui-anchorid"); + } + }, + + removeLocationAttributes(aNode) { + aNode.removeAttribute("cui-areatype"); + aNode.removeAttribute("cui-anchorid"); + }, + + insertNode(aWidgetId, aArea, aPosition, isNew) { + let areaNodes = gBuildAreas.get(aArea); + if (!areaNodes) { + return; + } + + let placements = gPlacements.get(aArea); + if (!placements) { + lazy.log.error( + "Could not find any placements for " + aArea + " when moving a widget." + ); + return; + } + + // Go through each of the nodes associated with this area and move the + // widget to the requested location. + for (let areaNode of areaNodes) { + this.insertNodeInWindow(aWidgetId, areaNode, isNew); + } + }, + + insertNodeInWindow(aWidgetId, aAreaNode, isNew) { + let window = aAreaNode.ownerGlobal; + let showInPrivateBrowsing = gPalette.has(aWidgetId) + ? gPalette.get(aWidgetId).showInPrivateBrowsing + : true; + + if ( + !showInPrivateBrowsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + return; + } + + let [, widgetNode] = this.getWidgetNode(aWidgetId, window); + if (!widgetNode) { + lazy.log.error("Widget '" + aWidgetId + "' not found, unable to move"); + return; + } + + let areaId = aAreaNode.id; + if (isNew) { + this.ensureButtonContextMenu(widgetNode, aAreaNode); + } + + let [insertionContainer, nextNode] = this.findInsertionPoints( + widgetNode, + aAreaNode + ); + this.insertWidgetBefore(widgetNode, nextNode, insertionContainer, areaId); + }, + + findInsertionPoints(aNode, aAreaNode) { + let areaId = aAreaNode.id; + let props = gAreas.get(areaId); + + // For overflowable toolbars, rely on them (because the work is more complicated): + if ( + props.get("type") == CustomizableUI.TYPE_TOOLBAR && + props.get("overflowable") + ) { + return aAreaNode.overflowable.findOverflowedInsertionPoints(aNode); + } + + let container = this.getCustomizationTarget(aAreaNode); + let placements = gPlacements.get(areaId); + let nodeIndex = placements.indexOf(aNode.id); + + while (++nodeIndex < placements.length) { + let nextNodeId = placements[nodeIndex]; + // We use aAreaNode here, because if aNode is in a template, its + // `ownerDocument` is *not* going to be the browser.xhtml document, + // so we cannot rely on it. + let nextNode = aAreaNode.ownerDocument.getElementById(nextNodeId); + // If the next placed widget exists, and is a direct child of the + // container, or wrapped in a customize mode wrapper (toolbarpaletteitem) + // inside the container, insert beside it. + // We have to check the parent to avoid errors when the placement ids + // are for nodes that are no longer customizable. + if ( + nextNode && + (nextNode.parentNode == container || + (nextNode.parentNode.localName == "toolbarpaletteitem" && + nextNode.parentNode.parentNode == container)) + ) { + return [container, nextNode]; + } + } + + return [container, null]; + }, + + insertWidgetBefore(aNode, aNextNode, aContainer, aArea) { + this.notifyDOMChange(aNode, aNextNode, aContainer, false, () => { + this.setLocationAttributes(aNode, aArea); + aContainer.insertBefore(aNode, aNextNode); + }); + }, + + notifyDOMChange(aNode, aNextNode, aContainer, aIsRemove, aCallback) { + this.notifyListeners( + "onWidgetBeforeDOMChange", + aNode, + aNextNode, + aContainer, + aIsRemove + ); + aCallback(); + this.notifyListeners( + "onWidgetAfterDOMChange", + aNode, + aNextNode, + aContainer, + aIsRemove + ); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "command": + if (!this._originalEventInPanel(aEvent)) { + break; + } + aEvent = aEvent.sourceEvent; + // Fall through + case "click": + case "keypress": + this.maybeAutoHidePanel(aEvent); + break; + case "unload": + this.unregisterBuildWindow(aEvent.currentTarget); + break; + } + }, + + _originalEventInPanel(aEvent) { + let e = aEvent.sourceEvent; + if (!e) { + return false; + } + let node = this._getPanelForNode(e.target); + if (!node) { + return false; + } + let win = e.view; + let panels = gPanelsForWindow.get(win); + return !!panels && panels.has(node); + }, + + _getSpecialIdForNode(aNode) { + if (typeof aNode == "object" && aNode.localName) { + if (aNode.id) { + return aNode.id; + } + if (aNode.localName.startsWith("toolbar")) { + return aNode.localName.substring(7); + } + return ""; + } + return aNode; + }, + + isSpecialWidget(aId) { + aId = this._getSpecialIdForNode(aId); + return ( + aId.startsWith(kSpecialWidgetPfx) || + aId.startsWith("separator") || + aId.startsWith("spring") || + aId.startsWith("spacer") + ); + }, + + matchingSpecials(aId1, aId2) { + aId1 = this._getSpecialIdForNode(aId1); + aId2 = this._getSpecialIdForNode(aId2); + + return ( + this.isSpecialWidget(aId1) && + this.isSpecialWidget(aId2) && + aId1.match(/spring|spacer|separator/)[0] == + aId2.match(/spring|spacer|separator/)[0] + ); + }, + + ensureSpecialWidgetId(aId) { + let nodeType = aId.match(/spring|spacer|separator/)[0]; + // If the ID we were passed isn't a generated one, generate one now: + if (nodeType == aId) { + // Ids are differentiated through a unique count suffix. + return kSpecialWidgetPfx + aId + ++gNewElementCount; + } + return aId; + }, + + createSpecialWidget(aId, aDocument) { + let nodeName = "toolbar" + aId.match(/spring|spacer|separator/)[0]; + let node = aDocument.createXULElement(nodeName); + node.className = "chromeclass-toolbar-additional"; + node.id = this.ensureSpecialWidgetId(aId); + return node; + }, + + /* Find a XUL-provided widget in a window. Don't try to use this + * for an API-provided widget or a special widget. + */ + findWidgetInWindow(aId, aWindow) { + if (!gBuildWindows.has(aWindow)) { + throw new Error("Build window not registered"); + } + + if (!aId) { + lazy.log.error("findWidgetInWindow was passed an empty string."); + return null; + } + + let document = aWindow.document; + + // look for a node with the same id, as the node may be + // in a different toolbar. + let node = document.getElementById(aId); + if (node) { + let parent = node.parentNode; + while ( + parent && + !( + this.getCustomizationTarget(parent) || + parent == aWindow.gNavToolbox.palette + ) + ) { + parent = parent.parentNode; + } + + if (parent) { + let nodeInArea = + node.parentNode.localName == "toolbarpaletteitem" + ? node.parentNode + : node; + // Check if we're in a customization target, or in the palette: + if ( + (this.getCustomizationTarget(parent) == nodeInArea.parentNode && + gBuildWindows.get(aWindow).has(aWindow.gNavToolbox)) || + aWindow.gNavToolbox.palette == nodeInArea.parentNode + ) { + // Normalize the removable attribute. For backwards compat, if + // the widget is not located in a toolbox palette then absence + // of the "removable" attribute means it is not removable. + if (!node.hasAttribute("removable")) { + // If we first see this in customization mode, it may be in the + // customization palette instead of the toolbox palette. + node.setAttribute( + "removable", + !this.getCustomizationTarget(parent) + ); + } + return node; + } + } + } + + let toolboxes = gBuildWindows.get(aWindow); + for (let toolbox of toolboxes) { + if (toolbox.palette) { + // Attempt to locate an element with a matching ID within + // the palette. + let element = toolbox.palette.getElementsByAttribute("id", aId)[0]; + if (element) { + // Normalize the removable attribute. For backwards compat, this + // is optional if the widget is located in the toolbox palette, + // and defaults to *true*, unlike if it was located elsewhere. + if (!element.hasAttribute("removable")) { + element.setAttribute("removable", true); + } + return element; + } + } + } + return null; + }, + + buildWidget(aDocument, aWidget) { + if (aDocument.documentURI != kExpectedWindowURL) { + throw new Error("buildWidget was called for a non-browser window!"); + } + if (typeof aWidget == "string") { + aWidget = gPalette.get(aWidget); + } + if (!aWidget) { + throw new Error("buildWidget was passed a non-widget to build."); + } + if ( + !aWidget.showInPrivateBrowsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView) + ) { + return null; + } + + lazy.log.debug("Building " + aWidget.id + " of type " + aWidget.type); + + let node; + let button; + if (aWidget.type == "custom") { + if (aWidget.onBuild) { + node = aWidget.onBuild(aDocument); + } + if ( + !node || + !aDocument.defaultView.XULElement.isInstance(node) || + (aWidget.viewId && !node.viewButton) + ) { + lazy.log.error( + "Custom widget with id " + + aWidget.id + + " does not return a valid node" + ); + } + // A custom widget can define a viewId for the panel and a viewButton + // property for the panel anchor. With that, it will be treated as a view + // type where necessary to hook up the view panel. + if (aWidget.viewId) { + button = node.viewButton; + } + } + // Button and view widget types, plus custom widgets that have a viewId and thus a button. + if (button || aWidget.type != "custom") { + if ( + aWidget.onBeforeCreated && + aWidget.onBeforeCreated(aDocument) === false + ) { + return null; + } + + if (!button) { + button = aDocument.createXULElement("toolbarbutton"); + node = button; + } + button.classList.add("toolbarbutton-1"); + button.setAttribute("delegatesanchor", "true"); + + let viewbutton = null; + if (aWidget.type == "button-and-view") { + button.setAttribute("id", aWidget.id + "-button"); + let dropmarker = aDocument.createXULElement("toolbarbutton"); + dropmarker.setAttribute("id", aWidget.id + "-dropmarker"); + dropmarker.setAttribute("delegatesanchor", "true"); + dropmarker.classList.add( + "toolbarbutton-1", + "toolbarbutton-combined-buttons-dropmarker" + ); + node = aDocument.createXULElement("toolbaritem"); + node.classList.add("toolbaritem-combined-buttons"); + node.append(button, dropmarker); + viewbutton = dropmarker; + } else if (aWidget.viewId) { + // Also set viewbutton for anything with a view + viewbutton = button; + } + + node.setAttribute("id", aWidget.id); + node.setAttribute("widget-id", aWidget.id); + node.setAttribute("widget-type", aWidget.type); + if (aWidget.disabled) { + node.setAttribute("disabled", true); + } + node.setAttribute("removable", aWidget.removable); + node.setAttribute("overflows", aWidget.overflows); + if (aWidget.tabSpecific) { + node.setAttribute("tabspecific", aWidget.tabSpecific); + } + if (aWidget.locationSpecific) { + node.setAttribute("locationspecific", aWidget.locationSpecific); + } + if (aWidget.keepBroadcastAttributesWhenCustomizing) { + node.setAttribute( + "keepbroadcastattributeswhencustomizing", + aWidget.keepBroadcastAttributesWhenCustomizing + ); + } + + let shortcut; + if (aWidget.shortcutId) { + let keyEl = aDocument.getElementById(aWidget.shortcutId); + if (keyEl) { + shortcut = lazy.ShortcutUtils.prettifyShortcut(keyEl); + } else { + lazy.log.error( + "Key element with id '" + + aWidget.shortcutId + + "' for widget '" + + aWidget.id + + "' not found!" + ); + } + } + + if (aWidget.l10nId) { + aDocument.l10n.setAttributes(node, aWidget.l10nId); + if (button != node) { + // This is probably a "button-and-view" widget, such as the Profiler + // button. In that case, "node" is the "toolbaritem" container, and + // "button" the main button (see above). + // In this case, the values on the "node" is used in the Customize + // view, as well as the tooltips over both buttons; the values on the + // "button" are used in the overflow menu. + aDocument.l10n.setAttributes(button, aWidget.l10nId); + } + + if (shortcut) { + node.setAttribute("data-l10n-args", JSON.stringify({ shortcut })); + if (button != node) { + // This is probably a "button-and-view" widget. + button.setAttribute("data-l10n-args", JSON.stringify({ shortcut })); + } + } + } else { + node.setAttribute("label", this.getLocalizedProperty(aWidget, "label")); + if (button != node) { + // This is probably a "button-and-view" widget. + button.setAttribute("label", node.getAttribute("label")); + } + + let tooltip = this.getLocalizedProperty( + aWidget, + "tooltiptext", + shortcut ? [shortcut] : [] + ); + if (tooltip) { + node.setAttribute("tooltiptext", tooltip); + if (button != node) { + // This is probably a "button-and-view" widget. + button.setAttribute("tooltiptext", tooltip); + } + } + } + + let commandHandler = this.handleWidgetCommand.bind(this, aWidget, node); + node.addEventListener("command", commandHandler); + let clickHandler = this.handleWidgetClick.bind(this, aWidget, node); + node.addEventListener("click", clickHandler); + + node.classList.add("chromeclass-toolbar-additional"); + + // If the widget has a view, register a keypress handler because opening + // a view with the keyboard has slightly different focus handling than + // opening a view with the mouse. (When opened with the keyboard, the + // first item in the view should be focused after opening.) + if (viewbutton) { + lazy.log.debug( + "Widget " + + aWidget.id + + " has a view. Auto-registering event handlers." + ); + + if (aWidget.source == CustomizableUI.SOURCE_BUILTIN) { + node.classList.add("subviewbutton-nav"); + } + } + + if (aWidget.onCreated) { + aWidget.onCreated(node); + } + } + + aWidget.instances.set(aDocument, node); + return node; + }, + + ensureSubviewListeners(viewNode) { + if (viewNode._addedEventListeners) { + return; + } + let viewId = viewNode.id; + let widget = [...gPalette.values()].find(w => w.viewId == viewId); + if (!widget) { + return; + } + for (let eventName of kSubviewEvents) { + let handler = "on" + eventName; + if (typeof widget[handler] == "function") { + viewNode.addEventListener(eventName, widget[handler]); + } + } + viewNode._addedEventListeners = true; + lazy.log.debug( + "Widget " + widget.id + " showing and hiding event handlers set." + ); + }, + + getLocalizedProperty(aWidget, aProp, aFormatArgs, aDef) { + const kReqStringProps = ["label"]; + + if (typeof aWidget == "string") { + aWidget = gPalette.get(aWidget); + } + if (!aWidget) { + throw new Error( + "getLocalizedProperty was passed a non-widget to work with." + ); + } + let def, name; + // Let widgets pass their own string identifiers or strings, so that + // we can use strings which aren't the default (in case string ids change) + // and so that non-builtin-widgets can also provide labels, tooltips, etc. + if (aWidget[aProp] != null) { + name = aWidget[aProp]; + // By using this as the default, if a widget provides a full string rather + // than a string ID for localization, we will fall back to that string + // and return that. + def = aDef || name; + } else { + name = aWidget.id + "." + aProp; + def = aDef || ""; + } + if (aWidget.localized === false) { + return def; + } + try { + if (Array.isArray(aFormatArgs) && aFormatArgs.length) { + return ( + lazy.gWidgetsBundle.formatStringFromName(name, aFormatArgs) || def + ); + } + return lazy.gWidgetsBundle.GetStringFromName(name) || def; + } catch (ex) { + // If an empty string was explicitly passed, treat it as an actual + // value rather than a missing property. + if (!def && (name != "" || kReqStringProps.includes(aProp))) { + lazy.log.error("Could not localize property '" + name + "'."); + } + } + return def; + }, + + addShortcut(aShortcutNode, aTargetNode = aShortcutNode) { + // Detect if we've already been here before. + if (aTargetNode.hasAttribute("shortcut")) { + return; + } + + // Use ownerGlobal.document to ensure we get the right doc even for + // elements in template tags. + let { document } = aShortcutNode.ownerGlobal; + let shortcutId = aShortcutNode.getAttribute("key"); + let shortcut; + if (shortcutId) { + shortcut = document.getElementById(shortcutId); + } else { + let commandId = aShortcutNode.getAttribute("command"); + if (commandId) { + shortcut = lazy.ShortcutUtils.findShortcut( + document.getElementById(commandId) + ); + } + } + if (!shortcut) { + return; + } + + aTargetNode.setAttribute( + "shortcut", + lazy.ShortcutUtils.prettifyShortcut(shortcut) + ); + }, + + doWidgetCommand(aWidget, aNode, aEvent) { + if (aWidget.onCommand) { + try { + aWidget.onCommand.call(null, aEvent); + } catch (e) { + lazy.log.error(e); + } + } else { + // XXXunf Need to think this through more, and formalize. + Services.obs.notifyObservers( + aNode, + "customizedui-widget-command", + aWidget.id + ); + } + }, + + showWidgetView(aWidget, aNode, aEvent) { + let ownerWindow = aNode.ownerGlobal; + let area = this.getPlacementOfWidget(aNode.id).area; + let areaType = CustomizableUI.getAreaType(area); + let anchor = aNode; + + if ( + aWidget.disallowSubView && + (areaType == CustomizableUI.TYPE_PANEL || + aNode.hasAttribute("overflowedItem")) + ) { + // Close the containing panel (e.g. overflow), PanelUI will reopen. + let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); + if (wrapper?.anchor) { + this.hidePanelForNode(aNode); + anchor = wrapper.anchor; + } + } else if (areaType != CustomizableUI.TYPE_PANEL) { + let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); + + let hasMultiView = !!aNode.closest("panelmultiview"); + if (!hasMultiView && wrapper?.anchor) { + this.hidePanelForNode(aNode); + anchor = wrapper.anchor; + } + } + ownerWindow.PanelUI.showSubView(aWidget.viewId, anchor, aEvent); + }, + + handleWidgetCommand(aWidget, aNode, aEvent) { + // Note that aEvent can be a keypress event for widgets of type "view". + lazy.log.debug("handleWidgetCommand"); + + let action; + if (aWidget.onBeforeCommand) { + try { + action = aWidget.onBeforeCommand.call(null, aEvent, aNode); + } catch (e) { + lazy.log.error(e); + } + } + + if (aWidget.type == "button" || action == "command") { + this.doWidgetCommand(aWidget, aNode, aEvent); + } else if (aWidget.type == "view" || action == "view") { + this.showWidgetView(aWidget, aNode, aEvent); + } else if (aWidget.type == "button-and-view") { + // Do the command if we're in the toolbar and the button was clicked. + // Otherwise, including when we have currently overflowed out of the + // toolbar, open the view. There is no way to trigger the command while + // the widget is in the panel, by design. + let button = aNode.firstElementChild; + let area = this.getPlacementOfWidget(aNode.id).area; + let areaType = CustomizableUI.getAreaType(area); + if ( + areaType == CustomizableUI.TYPE_TOOLBAR && + button.contains(aEvent.target) && + !aNode.hasAttribute("overflowedItem") + ) { + this.doWidgetCommand(aWidget, aNode, aEvent); + } else { + this.showWidgetView(aWidget, aNode, aEvent); + } + } + }, + + handleWidgetClick(aWidget, aNode, aEvent) { + lazy.log.debug("handleWidgetClick"); + if (aWidget.onClick) { + try { + aWidget.onClick.call(null, aEvent); + } catch (e) { + console.error(e); + } + } else { + // XXXunf Need to think this through more, and formalize. + Services.obs.notifyObservers( + aNode, + "customizedui-widget-click", + aWidget.id + ); + } + }, + + _getPanelForNode(aNode) { + return aNode.closest("panel"); + }, + + /* + * If people put things in the panel which need more than single-click interaction, + * we don't want to close it. Right now we check for text inputs and menu buttons. + * We also check for being outside of any toolbaritem/toolbarbutton, ie on a blank + * part of the menu, or on another menu (like a context menu inside the panel). + */ + _isOnInteractiveElement(aEvent) { + let panel = this._getPanelForNode(aEvent.currentTarget); + // This can happen in e.g. customize mode. If there's no panel, + // there's clearly nothing for us to close; pretend we're interactive. + if (!panel) { + return true; + } + + function getNextTarget(target) { + if (target.nodeType == target.DOCUMENT_NODE) { + if (!target.defaultView) { + // Err, we're done. + return null; + } + // Find containing browser or iframe element in the parent doc. + return target.defaultView.docShell.chromeEventHandler; + } + // Skip any parent shadow roots + return target.parentNode?.host?.parentNode || target.parentNode; + } + + // While keeping track of that, we go from the original target back up, + // to the panel if we have to. We bail as soon as we find an input, + // a toolbarbutton/item, or a menuItem. + for ( + let target = aEvent.originalTarget; + target && target != panel; + target = getNextTarget(target) + ) { + if (target.nodeType == target.DOCUMENT_NODE) { + // Skip out of iframes etc: + continue; + } + + // Break out of the loop immediately for disabled items, as we need to + // keep the menu open in that case. + if (target.getAttribute("disabled") == "true") { + return true; + } + + let tagName = target.localName; + if (tagName == "input" || tagName == "searchbar") { + return true; + } + if (tagName == "toolbaritem" || tagName == "toolbarbutton") { + // If we are in a type="menu" toolbarbutton, we'll now interact with + // the menu. + return target.getAttribute("type") == "menu"; + } + if (tagName == "menuitem") { + // If we're in a nested menu we don't need to close this panel. + return true; + } + } + + // We don't know what we interacted with, assume interactive. + return true; + }, + + hidePanelForNode(aNode) { + let panel = this._getPanelForNode(aNode); + if (panel) { + lazy.PanelMultiView.hidePopup(panel); + } + }, + + maybeAutoHidePanel(aEvent) { + let eventType = aEvent.type; + if (eventType == "keypress" && aEvent.keyCode != aEvent.DOM_VK_RETURN) { + return; + } + + if (eventType == "click" && aEvent.button != 0) { + return; + } + + // We don't check preventDefault - it makes sense that this was prevented, + // but we probably still want to close the panel. If consumers don't want + // this to happen, they should specify the closemenu attribute. + if (eventType != "command" && this._isOnInteractiveElement(aEvent)) { + return; + } + + // We can't use event.target because we might have passed an anonymous + // content boundary as well, and so target points to the outer element in + // that case. Unfortunately, this means we get anonymous child nodes instead + // of the real ones, so looking for the 'stoooop, don't close me' attributes + // is more involved. + let target = aEvent.originalTarget; + while (target.parentNode && target.localName != "panel") { + if ( + target.getAttribute("closemenu") == "none" || + target.getAttribute("widget-type") == "view" || + target.getAttribute("widget-type") == "button-and-view" || + target.hasAttribute("view-button-id") + ) { + return; + } + target = target.parentNode; + } + + // If we get here, we can actually hide the popup: + this.hidePanelForNode(aEvent.target); + }, + + getUnusedWidgets(aWindowPalette) { + let window = aWindowPalette.ownerGlobal; + let isWindowPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(window); + // We use a Set because there can be overlap between the widgets in + // gPalette and the items in the palette, especially after the first + // customization, since programmatically generated widgets will remain + // in the toolbox palette. + let widgets = new Set(); + + // It's possible that some widgets have been defined programmatically and + // have not been overlayed into the palette. We can find those inside + // gPalette. + for (let [id, widget] of gPalette) { + if (!widget.currentArea) { + if (widget.showInPrivateBrowsing || !isWindowPrivate) { + widgets.add(id); + } + } + } + + lazy.log.debug("Iterating the actual nodes of the window palette"); + for (let node of aWindowPalette.children) { + lazy.log.debug("In palette children: " + node.id); + if (node.id && !this.getPlacementOfWidget(node.id)) { + widgets.add(node.id); + } + } + + return [...widgets]; + }, + + getPlacementOfWidget(aWidgetId, aOnlyRegistered, aDeadAreas) { + if (aOnlyRegistered && !this.widgetExists(aWidgetId)) { + return null; + } + + for (let [area, placements] of gPlacements) { + if (!gAreas.has(area) && !aDeadAreas) { + continue; + } + let index = placements.indexOf(aWidgetId); + if (index != -1) { + return { area, position: index }; + } + } + + return null; + }, + + widgetExists(aWidgetId) { + if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { + return true; + } + + // Destroyed API widgets are in gSeenWidgets, but not in gPalette: + // The Pocket button is a default API widget that acts like a custom widget. + // If it's not in gPalette, it doesn't exist. + if (gSeenWidgets.has(aWidgetId) || aWidgetId === "save-to-pocket-button") { + return false; + } + + // We're assuming XUL widgets always exist, as it's much harder to check, + // and checking would be much more error prone. + return true; + }, + + addWidgetToArea(aWidgetId, aArea, aPosition, aInitialAdd) { + if (aArea == CustomizableUI.AREA_NO_AREA) { + throw new Error( + "AREA_NO_AREA is only used as an argument for " + + "canWidgetMoveToArea. Use removeWidgetFromArea instead." + ); + } + if (!gAreas.has(aArea)) { + throw new Error("Unknown customization area: " + aArea); + } + + // Hack: don't want special widgets in the panel (need to check here as well + // as in canWidgetMoveToArea because the menu panel is lazy): + if ( + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL && + this.isSpecialWidget(aWidgetId) + ) { + return; + } + + // If this is a lazy area that hasn't been restored yet, we can't yet modify + // it - would would at least like to add to it. So we keep track of it in + // gFuturePlacements, and use that to add it when restoring the area. We + // throw away aPosition though, as that can only be bogus if the area hasn't + // yet been restorted (caller can't possibly know where its putting the + // widget in relation to other widgets). + if (this.isAreaLazy(aArea)) { + gFuturePlacements.get(aArea).add(aWidgetId); + return; + } + + if (this.isSpecialWidget(aWidgetId)) { + aWidgetId = this.ensureSpecialWidgetId(aWidgetId); + } + + let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); + if (oldPlacement && oldPlacement.area == aArea) { + this.moveWidgetWithinArea(aWidgetId, aPosition); + return; + } + + // Do nothing if the widget is not allowed to move to the target area. + if (!this.canWidgetMoveToArea(aWidgetId, aArea)) { + return; + } + + if (oldPlacement) { + this.removeWidgetFromArea(aWidgetId); + } + + if (!gPlacements.has(aArea)) { + gPlacements.set(aArea, [aWidgetId]); + aPosition = 0; + } else { + let placements = gPlacements.get(aArea); + if (typeof aPosition != "number") { + aPosition = placements.length; + } + if (aPosition < 0) { + aPosition = 0; + } + placements.splice(aPosition, 0, aWidgetId); + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + widget.currentArea = aArea; + widget.currentPosition = aPosition; + } + + // We initially set placements with addWidgetToArea, so in that case + // we don't consider the area "dirtied". + if (!aInitialAdd) { + gDirtyAreaCache.add(aArea); + } + + gDirty = true; + this.saveState(); + + this.notifyListeners("onWidgetAdded", aWidgetId, aArea, aPosition); + }, + + removeWidgetFromArea(aWidgetId) { + let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); + if (!oldPlacement) { + return; + } + + if (!this.isWidgetRemovable(aWidgetId)) { + return; + } + + let placements = gPlacements.get(oldPlacement.area); + let position = placements.indexOf(aWidgetId); + if (position != -1) { + placements.splice(position, 1); + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + widget.currentArea = null; + widget.currentPosition = null; + } + + gDirty = true; + this.saveState(); + gDirtyAreaCache.add(oldPlacement.area); + + this.notifyListeners("onWidgetRemoved", aWidgetId, oldPlacement.area); + }, + + moveWidgetWithinArea(aWidgetId, aPosition) { + let oldPlacement = this.getPlacementOfWidget(aWidgetId); + if (!oldPlacement) { + return; + } + + let placements = gPlacements.get(oldPlacement.area); + if (typeof aPosition != "number") { + aPosition = placements.length; + } else if (aPosition < 0) { + aPosition = 0; + } else if (aPosition > placements.length) { + aPosition = placements.length; + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + widget.currentPosition = aPosition; + widget.currentArea = oldPlacement.area; + } + + if (aPosition == oldPlacement.position) { + return; + } + + placements.splice(oldPlacement.position, 1); + // If we just removed the item from *before* where it is now added, + // we need to compensate the position offset for that: + if (oldPlacement.position < aPosition) { + aPosition--; + } + placements.splice(aPosition, 0, aWidgetId); + + gDirty = true; + gDirtyAreaCache.add(oldPlacement.area); + + this.saveState(); + + this.notifyListeners( + "onWidgetMoved", + aWidgetId, + oldPlacement.area, + oldPlacement.position, + aPosition + ); + }, + + // Note that this does not populate gPlacements, which is done lazily. + // The panel area is an exception here. + loadSavedState() { + let state = Services.prefs.getCharPref(kPrefCustomizationState, ""); + if (!state) { + lazy.log.debug("No saved state found"); + // Nothing has been customized, so silently fall back to the defaults. + return; + } + try { + gSavedState = JSON.parse(state); + if (typeof gSavedState != "object" || gSavedState === null) { + throw new Error("Invalid saved state"); + } + } catch (e) { + Services.prefs.clearUserPref(kPrefCustomizationState); + gSavedState = {}; + lazy.log.debug( + "Error loading saved UI customization state, falling back to defaults." + ); + } + + if (!("placements" in gSavedState)) { + gSavedState.placements = {}; + } + + if (!("currentVersion" in gSavedState)) { + gSavedState.currentVersion = 0; + } + + gSeenWidgets = new Set(gSavedState.seen || []); + gDirtyAreaCache = new Set(gSavedState.dirtyAreaCache || []); + gNewElementCount = gSavedState.newElementCount || 0; + }, + + restoreStateForArea(aArea) { + let placementsPreexisted = gPlacements.has(aArea); + + this.beginBatchUpdate(); + try { + gRestoring = true; + + let restored = false; + if (placementsPreexisted) { + lazy.log.debug("Restoring " + aArea + " from pre-existing placements"); + for (let [position, id] of gPlacements.get(aArea).entries()) { + this.moveWidgetWithinArea(id, position); + } + gDirty = false; + restored = true; + } else { + gPlacements.set(aArea, []); + } + + if (!restored && gSavedState && aArea in gSavedState.placements) { + lazy.log.debug("Restoring " + aArea + " from saved state"); + let placements = gSavedState.placements[aArea]; + for (let id of placements) { + this.addWidgetToArea(id, aArea); + } + gDirty = false; + restored = true; + } + + if (!restored) { + lazy.log.debug("Restoring " + aArea + " from default state"); + let defaults = gAreas.get(aArea).get("defaultPlacements"); + if (defaults) { + for (let id of defaults) { + this.addWidgetToArea(id, aArea, null, true); + } + } + gDirty = false; + } + + // Finally, add widgets to the area that were added before the it was able + // to be restored. This can occur when add-ons register widgets for a + // lazily-restored area before it's been restored. + if (gFuturePlacements.has(aArea)) { + let areaPlacements = gPlacements.get(aArea); + for (let id of gFuturePlacements.get(aArea)) { + if (areaPlacements.includes(id)) { + continue; + } + this.addWidgetToArea(id, aArea); + } + gFuturePlacements.delete(aArea); + } + + lazy.log.debug( + "Placements for " + + aArea + + ":\n\t" + + gPlacements.get(aArea).join("\n\t") + ); + + gRestoring = false; + } finally { + this.endBatchUpdate(); + } + }, + + saveState() { + if (gInBatchStack || !gDirty) { + return; + } + // Clone because we want to modify this map: + let state = { + placements: new Map(gPlacements), + seen: gSeenWidgets, + dirtyAreaCache: gDirtyAreaCache, + currentVersion: kVersion, + newElementCount: gNewElementCount, + }; + + // Merge in previously saved areas if not present in gPlacements. + // This way, state is still persisted for e.g. temporarily disabled + // add-ons - see bug 989338. + if (gSavedState && gSavedState.placements) { + for (let area of Object.keys(gSavedState.placements)) { + if (!state.placements.has(area)) { + let placements = gSavedState.placements[area]; + state.placements.set(area, placements); + } + } + } + + lazy.log.debug("Saving state."); + let serialized = JSON.stringify(state, this.serializerHelper); + lazy.log.debug("State saved as: " + serialized); + Services.prefs.setCharPref(kPrefCustomizationState, serialized); + gDirty = false; + }, + + serializerHelper(aKey, aValue) { + if (typeof aValue == "object" && aValue.constructor.name == "Map") { + let result = {}; + for (let [mapKey, mapValue] of aValue) { + result[mapKey] = mapValue; + } + return result; + } + + if (typeof aValue == "object" && aValue.constructor.name == "Set") { + return [...aValue]; + } + + return aValue; + }, + + beginBatchUpdate() { + gInBatchStack++; + }, + + endBatchUpdate(aForceDirty) { + gInBatchStack--; + if (aForceDirty === true) { + gDirty = true; + } + if (gInBatchStack == 0) { + this.saveState(); + } else if (gInBatchStack < 0) { + throw new Error( + "The batch editing stack should never reach a negative number." + ); + } + }, + + addListener(aListener) { + gListeners.add(aListener); + }, + + removeListener(aListener) { + if (aListener == this) { + return; + } + + gListeners.delete(aListener); + }, + + notifyListeners(aEvent, ...aArgs) { + if (gRestoring) { + return; + } + + for (let listener of gListeners) { + try { + if (typeof listener[aEvent] == "function") { + listener[aEvent].apply(listener, aArgs); + } + } catch (e) { + lazy.log.error(e + " -- " + e.fileName + ":" + e.lineNumber); + } + } + }, + + _dispatchToolboxEventToWindow(aEventType, aDetails, aWindow) { + let evt = new aWindow.CustomEvent(aEventType, { + bubbles: true, + cancelable: true, + detail: aDetails, + }); + aWindow.gNavToolbox.dispatchEvent(evt); + }, + + dispatchToolboxEvent(aEventType, aDetails = {}, aWindow = null) { + if (aWindow) { + this._dispatchToolboxEventToWindow(aEventType, aDetails, aWindow); + return; + } + for (let [win] of gBuildWindows) { + this._dispatchToolboxEventToWindow(aEventType, aDetails, win); + } + }, + + createWidget(aProperties) { + let widget = this.normalizeWidget( + aProperties, + CustomizableUI.SOURCE_EXTERNAL + ); + // XXXunf This should probably throw. + if (!widget) { + lazy.log.error("unable to normalize widget"); + return undefined; + } + + gPalette.set(widget.id, widget); + + // Clear our caches: + gGroupWrapperCache.delete(widget.id); + for (let [win] of gBuildWindows) { + let cache = gSingleWrapperCache.get(win); + if (cache) { + cache.delete(widget.id); + } + } + + this.notifyListeners("onWidgetCreated", widget.id); + + if (widget.defaultArea) { + let addToDefaultPlacements = false; + let area = gAreas.get(widget.defaultArea); + if ( + !CustomizableUI.isBuiltinToolbar(widget.defaultArea) && + widget.defaultArea != CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ) { + addToDefaultPlacements = true; + } + + if (addToDefaultPlacements) { + if (area.has("defaultPlacements")) { + area.get("defaultPlacements").push(widget.id); + } else { + area.set("defaultPlacements", [widget.id]); + } + } + } + + // Look through previously saved state to see if we're restoring a widget. + let seenAreas = new Set(); + let widgetMightNeedAutoAdding = true; + for (let [area] of gPlacements) { + seenAreas.add(area); + let areaIsRegistered = gAreas.has(area); + let index = gPlacements.get(area).indexOf(widget.id); + if (index != -1) { + widgetMightNeedAutoAdding = false; + if (areaIsRegistered) { + widget.currentArea = area; + widget.currentPosition = index; + } + break; + } + } + + // Also look at saved state data directly in areas that haven't yet been + // restored. Can't rely on this for restored areas, as they may have + // changed. + if (widgetMightNeedAutoAdding && gSavedState) { + for (let area of Object.keys(gSavedState.placements)) { + if (seenAreas.has(area)) { + continue; + } + + let areaIsRegistered = gAreas.has(area); + let index = gSavedState.placements[area].indexOf(widget.id); + if (index != -1) { + widgetMightNeedAutoAdding = false; + if (areaIsRegistered) { + widget.currentArea = area; + widget.currentPosition = index; + } + break; + } + } + } + + // If we're restoring the widget to it's old placement, fire off the + // onWidgetAdded event - our own handler will take care of adding it to + // any build areas. + this.beginBatchUpdate(); + try { + if (widget.currentArea) { + this.notifyListeners( + "onWidgetAdded", + widget.id, + widget.currentArea, + widget.currentPosition + ); + } else if (widgetMightNeedAutoAdding) { + let autoAdd = Services.prefs.getBoolPref( + kPrefCustomizationAutoAdd, + true + ); + + // If the widget doesn't have an existing placement, and it hasn't been + // seen before, then add it to its default area so it can be used. + // If the widget is not removable, we *have* to add it to its default + // area here. + let canBeAutoAdded = autoAdd && !gSeenWidgets.has(widget.id); + if (!widget.currentArea && (!widget.removable || canBeAutoAdded)) { + if (widget.defaultArea) { + if (this.isAreaLazy(widget.defaultArea)) { + gFuturePlacements.get(widget.defaultArea).add(widget.id); + } else { + this.addWidgetToArea(widget.id, widget.defaultArea); + } + } + } + + // Extension widgets cannot enter the customization palette, so if + // at this point, we haven't found an area for them, move them into + // AREA_ADDONS. + if ( + !widget.currentArea && + CustomizableUI.isWebExtensionWidget(widget.id) + ) { + this.addWidgetToArea(widget.id, CustomizableUI.AREA_ADDONS); + } + } + } finally { + // Ensure we always have this widget in gSeenWidgets, and save + // state in case this needs to be done here. + gSeenWidgets.add(widget.id); + this.endBatchUpdate(true); + } + + this.notifyListeners( + "onWidgetAfterCreation", + widget.id, + widget.currentArea + ); + return widget.id; + }, + + createBuiltinWidget(aData) { + // This should only ever be called on startup, before any windows are + // opened - so we know there's no build areas to handle. Also, builtin + // widgets are expected to be (mostly) static, so shouldn't affect the + // current placement settings. + + // This allows a widget to be both built-in by default but also able to be + // destroyed and removed from the area based on criteria that may not be + // available when the widget is created -- for example, because some other + // feature in the browser supersedes the widget. + let conditionalDestroyPromise = aData.conditionalDestroyPromise || null; + delete aData.conditionalDestroyPromise; + + let widget = this.normalizeWidget(aData, CustomizableUI.SOURCE_BUILTIN); + if (!widget) { + lazy.log.error("Error creating builtin widget: " + aData.id); + return; + } + + lazy.log.debug("Creating built-in widget with id: " + widget.id); + gPalette.set(widget.id, widget); + + if (conditionalDestroyPromise) { + conditionalDestroyPromise.then( + shouldDestroy => { + if (shouldDestroy) { + this.destroyWidget(widget.id); + this.removeWidgetFromArea(widget.id); + } + }, + err => { + console.error(err); + } + ); + } + }, + + // Returns true if the area will eventually lazily restore (but hasn't yet). + isAreaLazy(aArea) { + if (gPlacements.has(aArea)) { + return false; + } + return gAreas.get(aArea).get("type") == CustomizableUI.TYPE_TOOLBAR; + }, + + // XXXunf Log some warnings here, when the data provided isn't up to scratch. + normalizeWidget(aData, aSource) { + let widget = { + implementation: aData, + source: aSource || CustomizableUI.SOURCE_EXTERNAL, + instances: new Map(), + currentArea: null, + localized: true, + removable: true, + overflows: true, + defaultArea: null, + shortcutId: null, + tabSpecific: false, + locationSpecific: false, + tooltiptext: null, + l10nId: null, + showInPrivateBrowsing: true, + _introducedInVersion: -1, + keepBroadcastAttributesWhenCustomizing: false, + disallowSubView: false, + webExtension: false, + }; + + if (typeof aData.id != "string" || !/^[a-z0-9-_]{1,}$/i.test(aData.id)) { + lazy.log.error("Given an illegal id in normalizeWidget: " + aData.id); + return null; + } + + delete widget.implementation.currentArea; + widget.implementation.__defineGetter__( + "currentArea", + () => widget.currentArea + ); + + const kReqStringProps = ["id"]; + for (let prop of kReqStringProps) { + if (typeof aData[prop] != "string") { + lazy.log.error( + "Missing required property '" + + prop + + "' in normalizeWidget: " + + aData.id + ); + return null; + } + widget[prop] = aData[prop]; + } + + const kOptStringProps = ["l10nId", "label", "tooltiptext", "shortcutId"]; + for (let prop of kOptStringProps) { + if (typeof aData[prop] == "string") { + widget[prop] = aData[prop]; + } + } + + const kOptBoolProps = [ + "removable", + "showInPrivateBrowsing", + "overflows", + "tabSpecific", + "locationSpecific", + "localized", + "keepBroadcastAttributesWhenCustomizing", + "disallowSubView", + "webExtension", + ]; + for (let prop of kOptBoolProps) { + if (typeof aData[prop] == "boolean") { + widget[prop] = aData[prop]; + } + } + + // When we normalize builtin widgets, areas have not yet been registered: + if ( + aData.defaultArea && + (aSource == CustomizableUI.SOURCE_BUILTIN || + gAreas.has(aData.defaultArea)) + ) { + widget.defaultArea = aData.defaultArea; + } else if (!widget.removable) { + lazy.log.error( + "Widget '" + + widget.id + + "' is not removable but does not specify " + + "a valid defaultArea. That's not possible; it must specify a " + + "valid defaultArea as well." + ); + return null; + } + + if ("type" in aData && gSupportedWidgetTypes.has(aData.type)) { + widget.type = aData.type; + } else { + widget.type = "button"; + } + + widget.disabled = aData.disabled === true; + + if (aSource == CustomizableUI.SOURCE_BUILTIN) { + widget._introducedInVersion = aData.introducedInVersion || 0; + } + + this.wrapWidgetEventHandler("onBeforeCreated", widget); + this.wrapWidgetEventHandler("onClick", widget); + this.wrapWidgetEventHandler("onCreated", widget); + this.wrapWidgetEventHandler("onDestroyed", widget); + + if (typeof aData.onBeforeCommand == "function") { + widget.onBeforeCommand = aData.onBeforeCommand; + } + + if (typeof aData.onCommand == "function") { + widget.onCommand = aData.onCommand; + } + if ( + widget.type == "view" || + widget.type == "button-and-view" || + aData.viewId + ) { + if (typeof aData.viewId != "string") { + lazy.log.error( + "Expected a string for widget " + + widget.id + + " viewId, but got " + + aData.viewId + ); + return null; + } + widget.viewId = aData.viewId; + + this.wrapWidgetEventHandler("onViewShowing", widget); + this.wrapWidgetEventHandler("onViewHiding", widget); + } + if (widget.type == "custom") { + this.wrapWidgetEventHandler("onBuild", widget); + } + + if (gPalette.has(widget.id)) { + return null; + } + + return widget; + }, + + wrapWidgetEventHandler(aEventName, aWidget) { + if (typeof aWidget.implementation[aEventName] != "function") { + aWidget[aEventName] = null; + return; + } + aWidget[aEventName] = function (...aArgs) { + try { + // Don't copy the function to the normalized widget object, instead + // keep it on the original object provided to the API so that + // additional methods can be implemented and used by the event + // handlers. + return aWidget.implementation[aEventName].apply( + aWidget.implementation, + aArgs + ); + } catch (e) { + console.error(e); + return undefined; + } + }; + }, + + destroyWidget(aWidgetId) { + let widget = gPalette.get(aWidgetId); + if (!widget) { + gGroupWrapperCache.delete(aWidgetId); + for (let [window] of gBuildWindows) { + let windowCache = gSingleWrapperCache.get(window); + if (windowCache) { + windowCache.delete(aWidgetId); + } + } + return; + } + + // Remove it from the default placements of an area if it was added there: + if (widget.defaultArea) { + let area = gAreas.get(widget.defaultArea); + if (area) { + let defaultPlacements = area.get("defaultPlacements"); + // We can assume this is present because if a widget has a defaultArea, + // we automatically create a defaultPlacements array for that area. + let widgetIndex = defaultPlacements.indexOf(aWidgetId); + if (widgetIndex != -1) { + defaultPlacements.splice(widgetIndex, 1); + } + } + } + + // This will not remove the widget from gPlacements - we want to keep the + // setting so the widget gets put back in it's old position if/when it + // returns. + for (let [window] of gBuildWindows) { + let windowCache = gSingleWrapperCache.get(window); + if (windowCache) { + windowCache.delete(aWidgetId); + } + let widgetNode = + window.document.getElementById(aWidgetId) || + window.gNavToolbox.palette.getElementsByAttribute("id", aWidgetId)[0]; + if (widgetNode) { + let container = widgetNode.parentNode; + this.notifyListeners( + "onWidgetBeforeDOMChange", + widgetNode, + null, + container, + true + ); + widgetNode.remove(); + this.notifyListeners( + "onWidgetAfterDOMChange", + widgetNode, + null, + container, + true + ); + } + if ( + widget.type == "view" || + widget.type == "button-and-view" || + widget.viewId + ) { + let viewNode = window.document.getElementById(widget.viewId); + if (viewNode) { + for (let eventName of kSubviewEvents) { + let handler = "on" + eventName; + if (typeof widget[handler] == "function") { + viewNode.removeEventListener(eventName, widget[handler]); + } + } + viewNode._addedEventListeners = false; + } + } + if (widgetNode && widget.onDestroyed) { + widget.onDestroyed(window.document); + } + } + + gPalette.delete(aWidgetId); + gGroupWrapperCache.delete(aWidgetId); + + this.notifyListeners("onWidgetDestroyed", aWidgetId); + }, + + getCustomizeTargetForArea(aArea, aWindow) { + let buildAreaNodes = gBuildAreas.get(aArea); + if (!buildAreaNodes) { + return null; + } + + for (let node of buildAreaNodes) { + if (node.ownerGlobal == aWindow) { + return this.getCustomizationTarget(node) || node; + } + } + + return null; + }, + + reset() { + gResetting = true; + this._resetUIState(); + + // Rebuild each registered area (across windows) to reflect the state that + // was reset above. + this._rebuildRegisteredAreas(); + + for (let [widgetId, widget] of gPalette) { + if (widget.source == CustomizableUI.SOURCE_EXTERNAL) { + gSeenWidgets.add(widgetId); + } + } + if (gSeenWidgets.size || gNewElementCount) { + gDirty = true; + this.saveState(); + } + + gResetting = false; + }, + + _resetUIState() { + try { + gUIStateBeforeReset.drawInTitlebar = + Services.prefs.getIntPref(kPrefDrawInTitlebar); + gUIStateBeforeReset.uiCustomizationState = Services.prefs.getCharPref( + kPrefCustomizationState + ); + gUIStateBeforeReset.uiDensity = Services.prefs.getIntPref(kPrefUIDensity); + gUIStateBeforeReset.autoTouchMode = + Services.prefs.getBoolPref(kPrefAutoTouchMode); + gUIStateBeforeReset.currentTheme = gSelectedTheme; + gUIStateBeforeReset.autoHideDownloadsButton = Services.prefs.getBoolPref( + kPrefAutoHideDownloadsButton + ); + gUIStateBeforeReset.newElementCount = gNewElementCount; + } catch (e) {} + + Services.prefs.clearUserPref(kPrefCustomizationState); + Services.prefs.clearUserPref(kPrefDrawInTitlebar); + Services.prefs.clearUserPref(kPrefUIDensity); + Services.prefs.clearUserPref(kPrefAutoTouchMode); + Services.prefs.clearUserPref(kPrefAutoHideDownloadsButton); + gDefaultTheme.enable(); + gNewElementCount = 0; + lazy.log.debug("State reset"); + + // Later in the function, we're going to add any area-less extension + // buttons to the AREA_ADDONS area. We'll remember the old placements + // for that area so that we don't need to re-add widgets that are already + // in there in the DOM. + let oldAddonPlacements = gPlacements[CustomizableUI.AREA_ADDONS] || []; + + // Reset placements to make restoring default placements possible. + gPlacements = new Map(); + gDirtyAreaCache = new Set(); + gSeenWidgets = new Set(); + // Clear the saved state to ensure that defaults will be used. + gSavedState = null; + // Restore the state for each area to its defaults + for (let [areaId] of gAreas) { + // If the Unified Extensions UI is enabled, we'll be adding any + // extension buttons that aren't already in AREA_ADDONS there, + // so we can skip restoring the state for it. + if (areaId != CustomizableUI.AREA_ADDONS) { + this.restoreStateForArea(areaId); + } + } + + // restoreStateForArea will have normally set an array for the placements + // for each area, but since we skip AREA_ADDONS intentionally, that array + // doesn't get set, so we do that manually here. + gPlacements.set(CustomizableUI.AREA_ADDONS, []); + + for (let [widgetId] of gPalette) { + if ( + CustomizableUI.isWebExtensionWidget(widgetId) && + !oldAddonPlacements.includes(widgetId) + ) { + this.addWidgetToArea(widgetId, CustomizableUI.AREA_ADDONS); + } + } + }, + + _rebuildRegisteredAreas() { + for (let [areaId, areaNodes] of gBuildAreas) { + let placements = gPlacements.get(areaId); + let isFirstChangedToolbar = true; + for (let areaNode of areaNodes) { + this.buildArea(areaId, placements, areaNode); + + let area = gAreas.get(areaId); + if (area.get("type") == CustomizableUI.TYPE_TOOLBAR) { + let defaultCollapsed = area.get("defaultCollapsed"); + let win = areaNode.ownerGlobal; + if (defaultCollapsed !== null) { + win.setToolbarVisibility( + areaNode, + typeof defaultCollapsed == "string" + ? defaultCollapsed + : !defaultCollapsed, + isFirstChangedToolbar + ); + } + } + isFirstChangedToolbar = false; + } + } + }, + + /** + * Undoes a previous reset, restoring the state of the UI to the state prior to the reset. + */ + undoReset() { + if ( + gUIStateBeforeReset.uiCustomizationState == null || + gUIStateBeforeReset.drawInTitlebar == null + ) { + return; + } + gUndoResetting = true; + + const { + uiCustomizationState, + drawInTitlebar, + currentTheme, + uiDensity, + autoTouchMode, + autoHideDownloadsButton, + } = gUIStateBeforeReset; + gNewElementCount = gUIStateBeforeReset.newElementCount; + + // Need to clear the previous state before setting the prefs + // because pref observers may check if there is a previous UI state. + this._clearPreviousUIState(); + + Services.prefs.setCharPref(kPrefCustomizationState, uiCustomizationState); + Services.prefs.setIntPref(kPrefDrawInTitlebar, drawInTitlebar); + Services.prefs.setIntPref(kPrefUIDensity, uiDensity); + Services.prefs.setBoolPref(kPrefAutoTouchMode, autoTouchMode); + Services.prefs.setBoolPref( + kPrefAutoHideDownloadsButton, + autoHideDownloadsButton + ); + currentTheme.enable(); + this.loadSavedState(); + // If the user just customizes toolbar/titlebar visibility, gSavedState will be null + // and we don't need to do anything else here: + if (gSavedState) { + for (let areaId of Object.keys(gSavedState.placements)) { + let placements = gSavedState.placements[areaId]; + gPlacements.set(areaId, placements); + } + this._rebuildRegisteredAreas(); + } + + gUndoResetting = false; + }, + + _clearPreviousUIState() { + Object.getOwnPropertyNames(gUIStateBeforeReset).forEach(prop => { + gUIStateBeforeReset[prop] = null; + }); + }, + + /** + * @param {String|Node} aWidget - widget ID or a widget node (preferred for performance). + * @return {Boolean} whether the widget is removable + */ + isWidgetRemovable(aWidget) { + let widgetId; + let widgetNode; + if (typeof aWidget == "string") { + widgetId = aWidget; + } else { + // Skipped items could just not have ids. + if (!aWidget.id && aWidget.getAttribute("skipintoolbarset") == "true") { + return false; + } + if ( + !aWidget.id && + !["toolbarspring", "toolbarspacer", "toolbarseparator"].includes( + aWidget.nodeName + ) + ) { + throw new Error( + "No nodes without ids that aren't special widgets should ever come into contact with CUI" + ); + } + // Use "spring" / "spacer" / "separator" for special widgets without ids + widgetId = + aWidget.id || aWidget.nodeName.substring(7 /* "toolbar".length */); + widgetNode = aWidget; + } + let provider = this.getWidgetProvider(widgetId); + + if (provider == CustomizableUI.PROVIDER_API) { + return gPalette.get(widgetId).removable; + } + + if (provider == CustomizableUI.PROVIDER_XUL) { + if (gBuildWindows.size == 0) { + // We don't have any build windows to look at, so just assume for now + // that its removable. + return true; + } + + if (!widgetNode) { + // Pick any of the build windows to look at. + let [window] = [...gBuildWindows][0]; + [, widgetNode] = this.getWidgetNode(widgetId, window); + } + // If we don't have a node, we assume it's removable. This can happen because + // getWidgetProvider returns PROVIDER_XUL by default, but this will also happen + // for API-provided widgets which have been destroyed. + if (!widgetNode) { + return true; + } + return widgetNode.getAttribute("removable") == "true"; + } + + // Otherwise this is either a special widget, which is always removable, or + // an API widget which has already been removed from gPalette. Returning true + // here allows us to then remove its ID from any placements where it might + // still occur. + return true; + }, + + canWidgetMoveToArea(aWidgetId, aArea) { + // Special widgets can't move to the menu panel. + if ( + this.isSpecialWidget(aWidgetId) && + gAreas.has(aArea) && + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL + ) { + return false; + } + + if ( + aArea == CustomizableUI.AREA_ADDONS && + !CustomizableUI.isWebExtensionWidget(aWidgetId) + ) { + return false; + } + + if (CustomizableUI.isWebExtensionWidget(aWidgetId)) { + // Extension widgets cannot move to the customization palette. + if (aArea == CustomizableUI.AREA_NO_AREA) { + return false; + } + + // Extension widgets cannot move to panels, with the exception of the + // AREA_ADDONS area. + if ( + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL && + aArea != CustomizableUI.AREA_ADDONS + ) { + return false; + } + } + + let placement = this.getPlacementOfWidget(aWidgetId); + // Items in the palette can move, and items can move within their area: + if (!placement || placement.area == aArea) { + return true; + } + // For everything else, just return whether the widget is removable. + return this.isWidgetRemovable(aWidgetId); + }, + + ensureWidgetPlacedInWindow(aWidgetId, aWindow) { + let placement = this.getPlacementOfWidget(aWidgetId); + if (!placement) { + return false; + } + let areaNodes = gBuildAreas.get(placement.area); + if (!areaNodes) { + return false; + } + let container = [...areaNodes].filter(n => n.ownerGlobal == aWindow); + if (!container.length) { + return false; + } + let existingNode = container[0].getElementsByAttribute("id", aWidgetId)[0]; + if (existingNode) { + return true; + } + + this.insertNodeInWindow(aWidgetId, container[0], true); + return true; + }, + + _getCurrentWidgetsInContainer(container) { + // Get a list of all the widget IDs in this container, including any that + // are overflown. + let currentWidgets = new Set(); + function addUnskippedChildren(parent) { + for (let node of parent.children) { + let realNode = + node.localName == "toolbarpaletteitem" + ? node.firstElementChild + : node; + if (realNode.getAttribute("skipintoolbarset") != "true") { + currentWidgets.add(realNode.id); + } + } + } + addUnskippedChildren(this.getCustomizationTarget(container)); + if (container.getAttribute("overflowing") == "true") { + let overflowTarget = container.getAttribute("default-overflowtarget"); + addUnskippedChildren( + container.ownerDocument.getElementById(overflowTarget) + ); + let webExtOverflowTarget = container.getAttribute( + "addon-webext-overflowtarget" + ); + addUnskippedChildren( + container.ownerDocument.getElementById(webExtOverflowTarget) + ); + } + // Then get the sorted list of placements, and filter based on the nodes + // that are present. This avoids including items that don't exist (e.g. ids + // of add-on items that the user has uninstalled). + let orderedPlacements = CustomizableUI.getWidgetIdsInArea(container.id); + return orderedPlacements.filter(w => currentWidgets.has(w)); + }, + + get inDefaultState() { + for (let [areaId, props] of gAreas) { + let defaultPlacements = props + .get("defaultPlacements") + .filter(item => this.widgetExists(item)); + let currentPlacements = gPlacements.get(areaId); + // We're excluding all of the placement IDs for items that do not exist, + // and items that have removable="false", + // because we don't want to consider them when determining if we're + // in the default state. This way, if an add-on introduces a widget + // and is then uninstalled, the leftover placement doesn't cause us to + // automatically assume that the buttons are not in the default state. + let buildAreaNodes = gBuildAreas.get(areaId); + if (buildAreaNodes && buildAreaNodes.size) { + let container = [...buildAreaNodes][0]; + let removableOrDefault = itemNodeOrItem => { + let item = (itemNodeOrItem && itemNodeOrItem.id) || itemNodeOrItem; + let isRemovable = this.isWidgetRemovable(itemNodeOrItem); + let isInDefault = defaultPlacements.includes(item); + return isRemovable || isInDefault; + }; + // Toolbars need to deal with overflown widgets (if any) - so + // specialcase them: + if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { + currentPlacements = + this._getCurrentWidgetsInContainer(container).filter( + removableOrDefault + ); + } else { + currentPlacements = currentPlacements.filter(item => { + let itemNode = container.getElementsByAttribute("id", item)[0]; + return itemNode && removableOrDefault(itemNode || item); + }); + } + + if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { + let collapsed = null; + let defaultCollapsed = props.get("defaultCollapsed"); + let nondefaultState = false; + if (areaId == CustomizableUI.AREA_BOOKMARKS) { + collapsed = Services.prefs.getCharPref( + "browser.toolbars.bookmarks.visibility" + ); + nondefaultState = Services.prefs.prefHasUserValue( + "browser.toolbars.bookmarks.visibility" + ); + } else { + let attribute = + container.getAttribute("type") == "menubar" + ? "autohide" + : "collapsed"; + collapsed = container.getAttribute(attribute) == "true"; + nondefaultState = collapsed != defaultCollapsed; + } + if (defaultCollapsed !== null && nondefaultState) { + lazy.log.debug( + "Found " + + areaId + + " had non-default toolbar visibility" + + "(expected " + + defaultCollapsed + + ", was " + + collapsed + + ")" + ); + return false; + } + } + } + lazy.log.debug( + "Checking default state for " + + areaId + + ":\n" + + currentPlacements.join(",") + + "\nvs.\n" + + defaultPlacements.join(",") + ); + + if (currentPlacements.length != defaultPlacements.length) { + return false; + } + + for (let i = 0; i < currentPlacements.length; ++i) { + if ( + currentPlacements[i] != defaultPlacements[i] && + !this.matchingSpecials(currentPlacements[i], defaultPlacements[i]) + ) { + lazy.log.debug( + "Found " + + currentPlacements[i] + + " in " + + areaId + + " where " + + defaultPlacements[i] + + " was expected!" + ); + return false; + } + } + } + + if (Services.prefs.prefHasUserValue(kPrefUIDensity)) { + lazy.log.debug(kPrefUIDensity + " pref is non-default"); + return false; + } + + if (Services.prefs.prefHasUserValue(kPrefAutoTouchMode)) { + lazy.log.debug(kPrefAutoTouchMode + " pref is non-default"); + return false; + } + + if (Services.prefs.prefHasUserValue(kPrefDrawInTitlebar)) { + lazy.log.debug(kPrefDrawInTitlebar + " pref is non-default"); + return false; + } + + // This should just be `gDefaultTheme.isActive`, but bugs... + if (gDefaultTheme && gDefaultTheme.id != gSelectedTheme.id) { + lazy.log.debug(gSelectedTheme.id + " theme is non-default"); + return false; + } + + return true; + }, + + getCollapsedToolbarIds(window) { + let collapsedToolbars = new Set(); + for (let toolbarId of CustomizableUIInternal._builtinToolbars) { + let toolbar = window.document.getElementById(toolbarId); + + // Menubar toolbars are special in that they're hidden with the autohide + // attribute. + let hidingAttribute = + toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; + + if (toolbar.getAttribute(hidingAttribute) == "true") { + collapsedToolbars.add(toolbarId); + } + } + + return collapsedToolbars; + }, + + setToolbarVisibility(aToolbarId, aIsVisible) { + // We only persist the attribute the first time. + let isFirstChangedToolbar = true; + for (let window of CustomizableUI.windows) { + let toolbar = window.document.getElementById(aToolbarId); + if (toolbar) { + window.setToolbarVisibility(toolbar, aIsVisible, isFirstChangedToolbar); + isFirstChangedToolbar = false; + } + } + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "browser-set-toolbar-visibility") { + let [toolbar, visibility] = JSON.parse(aData); + CustomizableUI.setToolbarVisibility(toolbar, visibility == "true"); + } + }, +}; +Object.freeze(CustomizableUIInternal); + +export var CustomizableUI = { + /** + * Constant reference to the ID of the navigation toolbar. + */ + AREA_NAVBAR: "nav-bar", + /** + * Constant reference to the ID of the menubar's toolbar. + */ + AREA_MENUBAR: "toolbar-menubar", + /** + * Constant reference to the ID of the tabstrip toolbar. + */ + AREA_TABSTRIP: "TabsToolbar", + /** + * Constant reference to the ID of the bookmarks toolbar. + */ + AREA_BOOKMARKS: "PersonalToolbar", + /** + * Constant reference to the ID of the non-dymanic (fixed) list in the overflow panel. + */ + AREA_FIXED_OVERFLOW_PANEL: "widget-overflow-fixed-list", + /** + * Constant reference to the ID of the addons area. + */ + AREA_ADDONS: "unified-extensions-area", + /** + * Constant reference to the ID of the customization palette, which is + * where widgets go when they're not assigned to an area. Note that this + * area is "virtual" in that it's never set as a value for a widgets + * currentArea or defaultArea. It's only used for the `canWidgetMoveToArea` + * function to check if widgets can be moved to the palette. Callers who + * wish to move items to the palette should use `removeWidgetFromArea`. + */ + AREA_NO_AREA: "customization-palette", + /** + * Constant indicating the area is a panel. + */ + TYPE_PANEL: "panel", + /** + * Constant indicating the area is a toolbar. + */ + TYPE_TOOLBAR: "toolbar", + + /** + * Constant indicating a XUL-type provider. + */ + PROVIDER_XUL: "xul", + /** + * Constant indicating an API-type provider. + */ + PROVIDER_API: "api", + /** + * Constant indicating dynamic (special) widgets: spring, spacer, and separator. + */ + PROVIDER_SPECIAL: "special", + + /** + * Constant indicating the widget is built-in + */ + SOURCE_BUILTIN: "builtin", + /** + * Constant indicating the widget is externally provided + * (e.g. by add-ons or other items not part of the builtin widget set). + */ + SOURCE_EXTERNAL: "external", + + /** + * Constant indicating the reason the event was fired was a window closing + */ + REASON_WINDOW_CLOSED: "window-closed", + /** + * Constant indicating the reason the event was fired was an area being + * unregistered separately from window closing mechanics. + */ + REASON_AREA_UNREGISTERED: "area-unregistered", + + /** + * An iteratable property of windows managed by CustomizableUI. + * Note that this can *only* be used as an iterator. ie: + * for (let window of CustomizableUI.windows) { ... } + */ + windows: { + *[Symbol.iterator]() { + for (let [window] of gBuildWindows) { + yield window; + } + }, + }, + + /** + * Add a listener object that will get fired for various events regarding + * customization. + * + * @param aListener the listener object to add + * + * Not all event handler methods need to be defined. + * CustomizableUI will catch exceptions. Events are dispatched + * synchronously on the UI thread, so if you can delay any/some of your + * processing, that is advisable. The following event handlers are supported: + * - onWidgetAdded(aWidgetId, aArea, aPosition) + * Fired when a widget is added to an area. aWidgetId is the widget that + * was added, aArea the area it was added to, and aPosition the position + * in which it was added. + * - onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) + * Fired when a widget is moved within its area. aWidgetId is the widget + * that was moved, aArea the area it was moved in, aOldPosition its old + * position, and aNewPosition its new position. + * - onWidgetRemoved(aWidgetId, aArea) + * Fired when a widget is removed from its area. aWidgetId is the widget + * that was removed, aArea the area it was removed from. + * + * - onWidgetBeforeDOMChange(aNode, aNextNode, aContainer, aIsRemoval) + * Fired *before* a widget's DOM node is acted upon by CustomizableUI + * (to add, move or remove it). aNode is the DOM node changed, aNextNode + * the DOM node (if any) before which a widget will be inserted, + * aContainer the *actual* DOM container (could be an overflow panel in + * case of an overflowable toolbar), and aWasRemoval is true iff the + * action about to happen is the removal of the DOM node. + * - onWidgetAfterDOMChange(aNode, aNextNode, aContainer, aWasRemoval) + * Like onWidgetBeforeDOMChange, but fired after the change to the DOM + * node of the widget. + * + * - onWidgetReset(aNode, aContainer) + * Fired after a reset to default placements moves a widget's node to a + * different location. aNode is the widget's node, aContainer is the + * area it was moved into (NB: it might already have been there and been + * moved to a different position!) + * - onWidgetUndoMove(aNode, aContainer) + * Fired after undoing a reset to default placements moves a widget's + * node to a different location. aNode is the widget's node, aContainer + * is the area it was moved into (NB: it might already have been there + * and been moved to a different position!) + * - onAreaReset(aArea, aContainer) + * Fired after a reset to default placements is complete on an area's + * DOM node. Note that this is fired for each DOM node. aArea is the area + * that was reset, aContainer the DOM node that was reset. + * + * - onWidgetCreated(aWidgetId) + * Fired when a widget with id aWidgetId has been created, but before it + * is added to any placements or any DOM nodes have been constructed. + * Only fired for API-based widgets. + * - onWidgetAfterCreation(aWidgetId, aArea) + * Fired after a widget with id aWidgetId has been created, and has been + * added to either its default area or the area in which it was placed + * previously. If the widget has no default area and/or it has never + * been placed anywhere, aArea may be null. Only fired for API-based + * widgets. + * - onWidgetDestroyed(aWidgetId) + * Fired when widgets are destroyed. aWidgetId is the widget that is + * being destroyed. Only fired for API-based widgets. + * - onWidgetInstanceRemoved(aWidgetId, aDocument) + * Fired when a window is unloaded and a widget's instance is destroyed + * because of this. Only fired for API-based widgets. + * + * - onWidgetDrag(aWidgetId, aArea) + * Fired both when and after customize mode drag handling system tries + * to determine the width and height of widget aWidgetId when dragged to a + * different area. aArea will be the area the item is dragged to, or + * undefined after the measurements have been done and the node has been + * moved back to its 'regular' area. + * + * - onCustomizeStart(aWindow) + * Fired when opening customize mode in aWindow. + * - onCustomizeEnd(aWindow) + * Fired when exiting customize mode in aWindow. + * + * - onWidgetOverflow(aNode, aContainer) + * Fired when a widget's DOM node is overflowing its container, a toolbar, + * and will be displayed in the overflow panel. + * - onWidgetUnderflow(aNode, aContainer) + * Fired when a widget's DOM node is *not* overflowing its container, a + * toolbar, anymore. + * - onWindowOpened(aWindow) + * Fired when a window has been opened that is managed by CustomizableUI, + * once all of the prerequisite setup has been done. + * - onWindowClosed(aWindow) + * Fired when a window that has been managed by CustomizableUI has been + * closed. + * - onAreaNodeRegistered(aArea, aContainer) + * Fired after an area node is first built when it is registered. This + * is often when the window has opened, but in the case of add-ons, + * could fire when the node has just been registered with CustomizableUI + * after an add-on update or disable/enable sequence. + * - onAreaNodeUnregistered(aArea, aContainer, aReason) + * Fired when an area node is explicitly unregistered by an API caller, + * or by a window closing. The aReason parameter indicates which of + * these is the case. + */ + addListener(aListener) { + CustomizableUIInternal.addListener(aListener); + }, + /** + * Remove a listener added with addListener + * @param aListener the listener object to remove + */ + removeListener(aListener) { + CustomizableUIInternal.removeListener(aListener); + }, + + /** + * Register a customizable area with CustomizableUI. + * @param aName the name of the area to register. Can only contain + * alphanumeric characters, dashes (-) and underscores (_). + * @param aProps the properties of the area. The following properties are + * recognized: + * - type: the type of area. Either TYPE_TOOLBAR (default) or + * TYPE_PANEL; + * - anchor: for a menu panel or overflowable toolbar, the + * anchoring node for the panel. + * - overflowable: set to true if your toolbar is overflowable. + * This requires an anchor, and only has an + * effect for toolbars. + * - defaultPlacements: an array of widget IDs making up the + * default contents of the area + * - defaultCollapsed: (INTERNAL ONLY) applies if the type is TYPE_TOOLBAR, specifies + * if toolbar is collapsed by default (default to true). + * Specify null to ensure that reset/inDefaultArea don't care + * about a toolbar's collapsed state + */ + registerArea(aName, aProperties) { + CustomizableUIInternal.registerArea(aName, aProperties); + }, + /** + * Register a concrete node for a registered area. This method needs to be called + * with any toolbar in the main browser window that has its "customizable" attribute + * set to true. + * + * Note that ideally, you should register your toolbar using registerArea + * before calling this. If you don't, the node will be saved for processing when + * you call registerArea. Note that CustomizableUI won't restore state in the area, + * allow the user to customize it in customize mode, or otherwise deal + * with it, until the area has been registered. + */ + registerToolbarNode(aToolbar) { + CustomizableUIInternal.registerToolbarNode(aToolbar); + }, + /** + * Register a panel node. A panel treated slightly differently from a toolbar in + * terms of what items can be moved into it. For example, a panel cannot have a + * spacer or a spring put into it. + * + * @param aPanelContents the panel contents DOM node being registered. + * @param aArea the area for which to register this node. + */ + registerPanelNode(aNode, aArea) { + CustomizableUIInternal.registerPanelNode(aNode, aArea); + }, + /** + * Unregister a customizable area. The inverse of registerArea. + * + * Unregistering an area will remove all the (removable) widgets in the + * area, which will return to the panel, and destroy all other traces + * of the area within CustomizableUI. Note that this means the *contents* + * of the area's DOM nodes will be moved to the panel or removed, but + * the area's DOM nodes *themselves* will stay. + * + * Furthermore, by default the placements of the area will be kept in the + * saved state (!) and restored if you re-register the area at a later + * point. This is useful for e.g. add-ons that get disabled and then + * re-enabled (e.g. when they update). + * + * You can override this last behaviour (and destroy the placements + * information in the saved state) by passing true for aDestroyPlacements. + * + * @param aName the name of the area to unregister + * @param aDestroyPlacements whether to destroy the placements information + * for the area, too. + */ + unregisterArea(aName, aDestroyPlacements) { + CustomizableUIInternal.unregisterArea(aName, aDestroyPlacements); + }, + /** + * Add a widget to an area. + * If the area to which you try to add is not known to CustomizableUI, + * this will throw. + * If the area to which you try to add is the same as the area in which + * the widget is currently placed, this will do the same as + * moveWidgetWithinArea. + * If the widget cannot be removed from its original location, this will + * no-op. + * + * This will fire an onWidgetAdded notification, + * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification + * for each window CustomizableUI knows about. + * + * @param aWidgetId the ID of the widget to add + * @param aArea the ID of the area to add the widget to + * @param aPosition the position at which to add the widget. If you do not + * pass a position, the widget will be added to the end + * of the area. + */ + addWidgetToArea(aWidgetId, aArea, aPosition) { + CustomizableUIInternal.addWidgetToArea(aWidgetId, aArea, aPosition); + }, + /** + * Remove a widget from its area. If the widget cannot be removed from its + * area, or is not in any area, this will no-op. Otherwise, this will fire an + * onWidgetRemoved notification, and an onWidgetBeforeDOMChange and + * onWidgetAfterDOMChange notification for each window CustomizableUI knows + * about. + * + * @param aWidgetId the ID of the widget to remove + */ + removeWidgetFromArea(aWidgetId) { + CustomizableUIInternal.removeWidgetFromArea(aWidgetId); + }, + /** + * Move a widget within an area. + * If the widget is not in any area, this will no-op. + * If the widget is already at the indicated position, this will no-op. + * + * Otherwise, this will move the widget and fire an onWidgetMoved notification, + * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification for + * each window CustomizableUI knows about. + * + * @param aWidgetId the ID of the widget to move + * @param aPosition the position to move the widget to. + * Negative values or values greater than the number of + * widgets will be interpreted to mean moving the widget to + * respectively the first or last position. + */ + moveWidgetWithinArea(aWidgetId, aPosition) { + CustomizableUIInternal.moveWidgetWithinArea(aWidgetId, aPosition); + }, + /** + * Ensure a XUL-based widget created in a window after areas were + * initialized moves to its correct position. + * Always prefer this over moving items in the DOM yourself. + * + * @param aWidgetId the ID of the widget that was just created + * @param aWindow the window in which you want to ensure it was added. + * + * NB: why is this API per-window, you wonder? Because if you need this, + * presumably you yourself need to create the widget in all the windows + * and need to loop through them anyway. + */ + ensureWidgetPlacedInWindow(aWidgetId, aWindow) { + return CustomizableUIInternal.ensureWidgetPlacedInWindow( + aWidgetId, + aWindow + ); + }, + /** + * Start a batch update of items. + * During a batch update, the customization state is not saved to the user's + * preferences file, in order to reduce (possibly sync) IO. + * Calls to begin/endBatchUpdate may be nested. + * + * Callers should ensure that NO MATTER WHAT they call endBatchUpdate once + * for each call to beginBatchUpdate, even if there are exceptions in the + * code in the batch update. Otherwise, for the duration of the + * Firefox session, customization state is never saved. Typically, you + * would do this using a try...finally block. + */ + beginBatchUpdate() { + CustomizableUIInternal.beginBatchUpdate(); + }, + /** + * End a batch update. See the documentation for beginBatchUpdate above. + * + * State is not saved if we believe it is identical to the last known + * saved state. State is only ever saved when all batch updates have + * finished (ie there has been 1 endBatchUpdate call for each + * beginBatchUpdate call). If any of the endBatchUpdate calls pass + * aForceDirty=true, we will flush to the prefs file. + * + * @param aForceDirty force CustomizableUI to flush to the prefs file when + * all batch updates have finished. + */ + endBatchUpdate(aForceDirty) { + CustomizableUIInternal.endBatchUpdate(aForceDirty); + }, + /** + * Create a widget. + * + * To create a widget, you should pass an object with its desired + * properties. The following properties are supported: + * + * - id: the ID of the widget (required). + * - type: a string indicating the type of widget. Possible types + * are: + * 'button' - for simple button widgets (the default) + * 'view' - for buttons that open a panel or subview, + * depending on where they are placed. + * 'button-and-view' - A combination of 'button' and 'view', + * which looks different depending on whether it's + * located in the toolbar or in the panel: When + * located in the toolbar, the widget is shown as + * a combined item of a button and a dropmarker + * button. The button triggers the command and the + * dropmarker button opens the view. When located + * in the panel, shown as one item which opens the + * view, and the button command cannot be + * triggered separately. + * 'custom' - for fine-grained control over the creation + * of the widget. + * - viewId: Only useful for views and button-and-view widgets (and + * required there): the id of the <panelview> that should be + * shown when clicking the widget. If used with a custom + * widget, the widget must also provide a toolbaritem where + * the first child is the view button. + * - onBuild(aDoc): Only useful for custom widgets (and required there); a + * function that will be invoked with the document in which + * to build a widget. Should return the DOM node that has + * been constructed. + * - onBeforeCreated(aDoc): Attached to all non-custom widgets; a function + * that will be invoked before the widget gets a DOM node + * constructed, passing the document in which that will happen. + * This is useful especially for 'view' type widgets that need + * to construct their views on the fly (e.g. from bootstrapped + * add-ons). If the function returns `false`, the widget will + * not be created. + * - onCreated(aNode): Attached to all widgets; a function that will be invoked + * whenever the widget has a DOM node constructed, passing the + * constructed node as an argument. + * - onDestroyed(aDoc): Attached to all non-custom widgets; a function that + * will be invoked after the widget has a DOM node destroyed, + * passing the document from which it was removed. This is + * useful especially for 'view' type widgets that need to + * cleanup after views that were constructed on the fly. + * - onBeforeCommand(aEvt, aNode): A function that will be invoked when the user + * activates the button but before the command + * is evaluated. Useful if code needs to run to + * change the button's icon in preparation to the + * pending command action. Called for any type that + * supports the handler. The command type, either + * "view" or "command", may be returned to force the + * action that will occur. View will open the panel + * and command will result in calling onCommand. + * - onCommand(aEvt): Useful for custom, button and button-and-view widgets; a + * function that will be invoked when the user activates + * the button. A custom widget with a view should + * return "view" or "command" to continue processing + * the command per the needs of the widget. + * - onClick(aEvt): Attached to all widgets; a function that will be invoked + * when the user clicks the widget. + * - onViewShowing(aEvt): Only useful for views and button-and-view widgets; a + * function that will be invoked when a user shows your view. + * If any event handler calls aEvt.preventDefault(), the view + * will not be shown. + * + * The event's `detail` property is an object with an + * `addBlocker` method. Handlers which need to + * perform asynchronous operations before the view is + * shown may pass this method a Promise, which will + * prevent the view from showing until it resolves. + * Additionally, if the promise resolves to the exact + * value `false`, the view will not be shown. + * - onViewHiding(aEvt): Only useful for views; a function that will be + * invoked when a user hides your view. + * - l10nId: fluent string identifier to use for localizing attributes + * on the widget. If present, preferred over the + * label/tooltiptext. + * - tooltiptext: string to use for the tooltip of the widget + * - label: string to use for the label of the widget + * - localized: If true, or undefined, attempt to retrieve the + * widget's string properties from the customizable + * widgets string bundle. + * - removable: whether the widget is removable (optional, default: true) + * NB: if you specify false here, you must provide a + * defaultArea, too. + * - overflows: whether widget can overflow when in an overflowable + * toolbar (optional, default: true) + * - defaultArea: default area to add the widget to + * (optional, default: none; required if non-removable) + * - shortcutId: id of an element that has a shortcut for this widget + * (optional, default: null). This is only used to display + * the shortcut as part of the tooltip for builtin widgets + * (which have strings inside + * customizableWidgets.properties). If you're in an add-on, + * you should not set this property. + * If l10nId is provided, the resulting shortcut is passed + * as the "$shortcut" variable to the fluent message. + * - showInPrivateBrowsing: whether to show the widget in private browsing + * mode (optional, default: true) + * - tabSpecific: If true, closes the panel if the tab changes. + * - locationSpecific: If true, closes the panel if the location changes. + * This is similar to tabSpecific, but also if the location + * changes in the same tab, we may want to close the panel. + * - webExtension: Set to true if this widget is being created on behalf of an + * extension. + * + * @param aProperties the specifications for the widget. + * @return a wrapper around the created widget (see getWidget) + */ + createWidget(aProperties) { + return CustomizableUIInternal.wrapWidget( + CustomizableUIInternal.createWidget(aProperties) + ); + }, + /** + * Destroy a widget + * + * If the widget is part of the default placements in an area, this will + * remove it from there. It will also remove any DOM instances. However, + * it will keep the widget in the placements for whatever area it was + * in at the time. You can remove it from there yourself by calling + * CustomizableUI.removeWidgetFromArea(aWidgetId). + * + * @param aWidgetId the ID of the widget to destroy + */ + destroyWidget(aWidgetId) { + CustomizableUIInternal.destroyWidget(aWidgetId); + }, + /** + * Get a wrapper object with information about the widget. + * The object provides the following properties + * (all read-only unless otherwise indicated): + * + * - id: the widget's ID; + * - type: the type of widget (button, view, custom). For + * XUL-provided widgets, this is always 'custom'; + * - provider: the provider type of the widget, id est one of + * PROVIDER_API or PROVIDER_XUL; + * - forWindow(w): a method to obtain a single window wrapper for a widget, + * in the window w passed as the only argument; + * - instances: an array of all instances (single window wrappers) + * of the widget. This array is NOT live; + * - areaType: the type of the widget's current area + * - isGroup: true; will be false for wrappers around single widget nodes; + * - source: for API-provided widgets, whether they are built-in to + * Firefox or add-on-provided; + * - disabled: for API-provided widgets, whether the widget is currently + * disabled. NB: this property is writable, and will toggle + * all the widgets' nodes' disabled states; + * - label: for API-provied widgets, the label of the widget; + * - tooltiptext: for API-provided widgets, the tooltip of the widget; + * - showInPrivateBrowsing: for API-provided widgets, whether the widget is + * visible in private browsing; + * + * Single window wrappers obtained through forWindow(someWindow) or from the + * instances array have the following properties + * (all read-only unless otherwise indicated): + * + * - id: the widget's ID; + * - type: the type of widget (button, view, custom). For + * XUL-provided widgets, this is always 'custom'; + * - provider: the provider type of the widget, id est one of + * PROVIDER_API or PROVIDER_XUL; + * - node: reference to the corresponding DOM node; + * - anchor: the anchor on which to anchor panels opened from this + * node. This will point to the overflow chevron on + * overflowable toolbars if and only if your widget node + * is overflowed, to the anchor for the panel menu + * if your widget is inside the panel menu, and to the + * node itself in all other cases; + * - overflowed: boolean indicating whether the node is currently in the + * overflow panel of the toolbar; + * - isGroup: false; will be true for the group widget; + * - label: for API-provided widgets, convenience getter for the + * label attribute of the DOM node; + * - tooltiptext: for API-provided widgets, convenience getter for the + * tooltiptext attribute of the DOM node; + * - disabled: for API-provided widgets, convenience getter *and setter* + * for the disabled state of this single widget. Note that + * you may prefer to use the group wrapper's getter/setter + * instead. + * + * @param aWidgetId the ID of the widget whose information you need + * @return a wrapper around the widget as described above, or null if the + * widget is known not to exist (anymore). NB: non-null return + * is no guarantee the widget exists because we cannot know in + * advance if a XUL widget exists or not. + */ + getWidget(aWidgetId) { + return CustomizableUIInternal.wrapWidget(aWidgetId); + }, + /** + * Get an array of widget wrappers (see getWidget) for all the widgets + * which are currently not in any area (so which are in the palette). + * + * @param aWindowPalette the palette (and by extension, the window) in which + * CustomizableUI should look. This matters because of + * course XUL-provided widgets could be available in + * some windows but not others, and likewise + * API-provided widgets might not exist in a private + * window (because of the showInPrivateBrowsing + * property). + * + * @return an array of widget wrappers (see getWidget) + */ + getUnusedWidgets(aWindowPalette) { + return CustomizableUIInternal.getUnusedWidgets(aWindowPalette).map( + CustomizableUIInternal.wrapWidget, + CustomizableUIInternal + ); + }, + /** + * Get an array of all the widget IDs placed in an area. + * Modifying the array will not affect CustomizableUI. + * + * @param aArea the ID of the area whose placements you want to obtain. + * @return an array containing the widget IDs that are in the area. + * + * NB: will throw if called too early (before placements have been fetched) + * or if the area is not currently known to CustomizableUI. + */ + getWidgetIdsInArea(aArea) { + if (!gAreas.has(aArea)) { + throw new Error("Unknown customization area: " + aArea); + } + if (!gPlacements.has(aArea)) { + throw new Error("Area not yet restored"); + } + + // We need to clone this, as we don't want to let consumers muck with placements + return [...gPlacements.get(aArea)]; + }, + /** + * Get an array of widget wrappers for all the widgets in an area. This is + * the same as calling getWidgetIdsInArea and .map() ing the result through + * CustomizableUI.getWidget. Careful: this means that if there are IDs in there + * which don't have corresponding DOM nodes, there might be nulls in this array, + * or items for which wrapper.forWindow(win) will return null. + * + * @param aArea the ID of the area whose widgets you want to obtain. + * @return an array of widget wrappers and/or null values for the widget IDs + * placed in an area. + * + * NB: will throw if called too early (before placements have been fetched) + * or if the area is not currently known to CustomizableUI. + */ + getWidgetsInArea(aArea) { + return this.getWidgetIdsInArea(aArea).map( + CustomizableUIInternal.wrapWidget, + CustomizableUIInternal + ); + }, + + /** + * Ensure the customizable widget that matches up with this view node + * will get the right subview showing/shown/hiding/hidden events when + * they fire. + * @param aViewNode the view node to add listeners to if they haven't + * been added already. + */ + ensureSubviewListeners(aViewNode) { + return CustomizableUIInternal.ensureSubviewListeners(aViewNode); + }, + /** + * Obtain an array of all the area IDs known to CustomizableUI. + * This array is created for you, so is modifiable without CustomizableUI + * being affected. + */ + get areas() { + return [...gAreas.keys()]; + }, + /** + * Check what kind of area (toolbar or menu panel) an area is. This is + * useful if you have a widget that needs to behave differently depending + * on its location. Note that widget wrappers have a convenience getter + * property (areaType) for this purpose. + * + * @param aArea the ID of the area whose type you want to know + * @return TYPE_TOOLBAR or TYPE_PANEL depending on the area, null if + * the area is unknown. + */ + getAreaType(aArea) { + let area = gAreas.get(aArea); + return area ? area.get("type") : null; + }, + /** + * Check if a toolbar is collapsed by default. + * + * @param aArea the ID of the area whose default-collapsed state you want to know. + * @return `true` or `false` depending on the area, null if the area is unknown, + * or its collapsed state cannot normally be controlled by the user + */ + isToolbarDefaultCollapsed(aArea) { + let area = gAreas.get(aArea); + return area ? area.get("defaultCollapsed") : null; + }, + /** + * Obtain the DOM node that is the customize target for an area in a + * specific window. + * + * Areas can have a customization target that does not correspond to the + * node itself. In particular, toolbars that have a customizationtarget + * attribute set will have their customization target set to that node. + * This means widgets will end up in the customization target, not in the + * DOM node with the ID that corresponds to the area ID. This is useful + * because it lets you have fixed content in a toolbar (e.g. the panel + * menu item in the navbar) and have all the customizable widgets use + * the customization target. + * + * Using this API yourself is discouraged; you should generally not need + * to be asking for the DOM container node used for a particular area. + * In particular, if you're wanting to check it in relation to a widget's + * node, your DOM node might not be a direct child of the customize target + * in a window if, for instance, the window is in customization mode, or if + * this is an overflowable toolbar and the widget has been overflowed. + * + * @param aArea the ID of the area whose customize target you want to have + * @param aWindow the window where you want to fetch the DOM node. + * @return the customize target DOM node for aArea in aWindow + */ + getCustomizeTargetForArea(aArea, aWindow) { + return CustomizableUIInternal.getCustomizeTargetForArea(aArea, aWindow); + }, + /** + * Reset the customization state back to its default. + * + * This is the nuclear option. You should never call this except if the user + * explicitly requests it. Firefox does this when the user clicks the + * "Restore Defaults" button in customize mode. + */ + reset() { + CustomizableUIInternal.reset(); + }, + + /** + * Undo the previous reset, can only be called immediately after a reset. + * @return a promise that will be resolved when the operation is complete. + */ + undoReset() { + CustomizableUIInternal.undoReset(); + }, + + /** + * Remove a custom toolbar added in a previous version of Firefox or using + * an add-on. NB: only works on the customizable toolbars generated by + * the toolbox itself. Intended for use from CustomizeMode, not by + * other consumers. + * @param aToolbarId the ID of the toolbar to remove + */ + removeExtraToolbar(aToolbarId) { + CustomizableUIInternal.removeExtraToolbar(aToolbarId); + }, + + /** + * Can the last Restore Defaults operation be undone. + * + * @return A boolean stating whether an undo of the + * Restore Defaults can be performed. + */ + get canUndoReset() { + return ( + gUIStateBeforeReset.uiCustomizationState != null || + gUIStateBeforeReset.drawInTitlebar != null || + gUIStateBeforeReset.currentTheme != null || + gUIStateBeforeReset.autoTouchMode != null || + gUIStateBeforeReset.uiDensity != null + ); + }, + + /** + * Get the placement of a widget. This is by far the best way to obtain + * information about what the state of your widget is. The internals of + * this call are cheap (no DOM necessary) and you will know where the user + * has put your widget. + * + * @param aWidgetId the ID of the widget whose placement you want to know + * @return + * { + * area: "somearea", // The ID of the area where the widget is placed + * position: 42 // the index in the placements array corresponding to + * // your widget. + * } + * + * OR + * + * null // if the widget is not placed anywhere (ie in the palette) + */ + getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) { + return CustomizableUIInternal.getPlacementOfWidget( + aWidgetId, + aOnlyRegistered, + aDeadAreas + ); + }, + /** + * Check if a widget can be removed from the area it's in. + * + * Note that if you're wanting to move the widget somewhere, you should + * generally be checking canWidgetMoveToArea, because that will return + * true if the widget is already in the area where you want to move it (!). + * + * NB: oh, also, this method might lie if the widget in question is a + * XUL-provided widget and there are no windows open, because it + * can obviously not check anything in this case. It will return + * true. You will be able to move the widget elsewhere. However, + * once the user reopens a window, the widget will move back to its + * 'proper' area automagically. + * + * @param aWidgetId a widget ID or DOM node to check + * @return true if the widget can be removed from its area, + * false otherwise. + */ + isWidgetRemovable(aWidgetId) { + return CustomizableUIInternal.isWidgetRemovable(aWidgetId); + }, + /** + * Check if a widget can be moved to a particular area. Like + * isWidgetRemovable but better, because it'll return true if the widget + * is already in the right area. + * + * @param aWidgetId the widget ID or DOM node you want to move somewhere + * @param aArea the area ID you want to move it to. This can also be + * AREA_NO_AREA to see if the widget can move to the + * customization palette, whether it's removable or not. + * @return true if this is possible, false if it is not. The same caveats as + * for isWidgetRemovable apply, however, if no windows are open. + */ + canWidgetMoveToArea(aWidgetId, aArea) { + return CustomizableUIInternal.canWidgetMoveToArea(aWidgetId, aArea); + }, + /** + * Whether we're in a default state. Note that non-removable non-default + * widgets and non-existing widgets are not taken into account in determining + * whether we're in the default state. + * + * NB: this is a property with a getter. The getter is NOT cheap, because + * it does smart things with non-removable non-default items, non-existent + * items, and so forth. Please don't call unless necessary. + */ + get inDefaultState() { + return CustomizableUIInternal.inDefaultState; + }, + + /** + * Set a toolbar's visibility state in all windows. + * @param aToolbarId the toolbar whose visibility should be adjusted + * @param aIsVisible whether the toolbar should be visible + */ + setToolbarVisibility(aToolbarId, aIsVisible) { + CustomizableUIInternal.setToolbarVisibility(aToolbarId, aIsVisible); + }, + + /** + * Returns a Set with the IDs of any registered toolbar areas that are + * currently collapsed in a particular window. Menubars that are set to + * autohide and are in the temporary "open" state are still considered + * collapsed by default. + * + * @param {Window} window The browser window to check for collapsed toolbars. + * @return {Set<string>} + */ + getCollapsedToolbarIds(window) { + return CustomizableUIInternal.getCollapsedToolbarIds(window); + }, + + /** + * DEPRECATED! Use fluent instead. + * + * Get a localized property off a (widget?) object. + * + * NB: this is unlikely to be useful unless you're in Firefox code, because + * this code uses the builtin widget stringbundle, and can't be told + * to use add-on-provided strings. It's mainly here as convenience for + * custom builtin widgets that build their own DOM but use the same + * stringbundle as the other builtin widgets. + * + * @param aWidget the object whose property we should use to fetch a + * localizable string; + * @param aProp the property on the object to use for the fetching; + * @param aFormatArgs (optional) any extra arguments to use for a formatted + * string; + * @param aDef (optional) the default to return if we don't find the + * string in the stringbundle; + * + * @return the localized string, or aDef if the string isn't in the bundle. + * If no default is provided, + * if aProp exists on aWidget, we'll return that, + * otherwise we'll return the empty string + * + */ + getLocalizedProperty(aWidget, aProp, aFormatArgs, aDef) { + return CustomizableUIInternal.getLocalizedProperty( + aWidget, + aProp, + aFormatArgs, + aDef + ); + }, + /** + * Utility function to detect, find and set a keyboard shortcut for a menuitem + * or (toolbar)button. + * + * @param aShortcutNode the XUL node where the shortcut will be derived from; + * @param aTargetNode (optional) the XUL node on which the `shortcut` + * attribute will be set. If NULL, the shortcut will be + * set on aShortcutNode; + */ + addShortcut(aShortcutNode, aTargetNode) { + return CustomizableUIInternal.addShortcut(aShortcutNode, aTargetNode); + }, + /** + * Given a node, walk up to the first panel in its ancestor chain, and + * close it. + * + * @param aNode a node whose panel should be closed; + */ + hidePanelForNode(aNode) { + CustomizableUIInternal.hidePanelForNode(aNode); + }, + /** + * Check if a widget is a "special" widget: a spring, spacer or separator. + * + * @param aWidgetId the widget ID to check. + * @return true if the widget is 'special', false otherwise. + */ + isSpecialWidget(aWidgetId) { + return CustomizableUIInternal.isSpecialWidget(aWidgetId); + }, + /** + * Check if a widget is provided by an extension. This effectively checks + * whether `webExtension: true` passed when the widget was being created. + * + * If the widget being referred to hasn't yet been created, or has been + * destroyed, we fallback to checking the ID for the "-browser-action" + * suffix. + * + * @param aWidgetId the widget ID to check. + * @return true if the widget was provided by an extension, false otherwise. + */ + isWebExtensionWidget(aWidgetId) { + let widget = CustomizableUI.getWidget(aWidgetId); + return widget?.webExtension || aWidgetId.endsWith("-browser-action"); + }, + /** + * Add listeners to a panel that will close it. For use from the menu panel + * and overflowable toolbar implementations, unlikely to be useful for + * consumers. + * + * @param aPanel the panel to which listeners should be attached. + */ + addPanelCloseListeners(aPanel) { + CustomizableUIInternal.addPanelCloseListeners(aPanel); + }, + /** + * Remove close listeners that have been added to a panel with + * addPanelCloseListeners. For use from the menu panel and overflowable + * toolbar implementations, unlikely to be useful for consumers. + * + * @param aPanel the panel from which listeners should be removed. + */ + removePanelCloseListeners(aPanel) { + CustomizableUIInternal.removePanelCloseListeners(aPanel); + }, + /** + * Notify listeners a widget is about to be dragged to an area. For use from + * Customize Mode only, do not use otherwise. + * + * @param aWidgetId the ID of the widget that is being dragged to an area. + * @param aArea the ID of the area to which the widget is being dragged. + */ + onWidgetDrag(aWidgetId, aArea) { + CustomizableUIInternal.notifyListeners("onWidgetDrag", aWidgetId, aArea); + }, + /** + * Notify listeners that a window is entering customize mode. For use from + * Customize Mode only, do not use otherwise. + * @param aWindow the window entering customize mode + */ + notifyStartCustomizing(aWindow) { + CustomizableUIInternal.notifyListeners("onCustomizeStart", aWindow); + }, + /** + * Notify listeners that a window is exiting customize mode. For use from + * Customize Mode only, do not use otherwise. + * @param aWindow the window exiting customize mode + */ + notifyEndCustomizing(aWindow) { + CustomizableUIInternal.notifyListeners("onCustomizeEnd", aWindow); + }, + + /** + * Notify toolbox(es) of a particular event. If you don't pass aWindow, + * all toolboxes will be notified. For use from Customize Mode only, + * do not use otherwise. + * @param aEvent the name of the event to send. + * @param aDetails optional, the details of the event. + * @param aWindow optional, the window in which to send the event. + */ + dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) { + CustomizableUIInternal.dispatchToolboxEvent(aEvent, aDetails, aWindow); + }, + + /** + * Check whether an area is overflowable. + * + * @param aAreaId the ID of an area to check for overflowable-ness + * @return true if the area is overflowable, false otherwise. + */ + isAreaOverflowable(aAreaId) { + let area = gAreas.get(aAreaId); + return area + ? area.get("type") == this.TYPE_TOOLBAR && area.get("overflowable") + : false; + }, + /** + * Obtain a string indicating the place of an element. This is intended + * for use from customize mode; You should generally use getPlacementOfWidget + * instead, which is cheaper because it does not use the DOM. + * + * @param aElement the DOM node whose place we need to check + * @return "toolbar" if the node is in a toolbar, "panel" if it is in the + * menu panel, "palette" if it is in the (visible!) customization + * palette, undefined otherwise. + */ + getPlaceForItem(aElement) { + let place; + let node = aElement; + while (node && !place) { + if (node.localName == "toolbar") { + place = "toolbar"; + } else if (node.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) { + place = "panel"; + } else if (node.id == "customization-palette") { + place = "palette"; + } + + node = node.parentNode; + } + return place; + }, + + /** + * Check if a toolbar is builtin or not. + * @param aToolbarId the ID of the toolbar you want to check + */ + isBuiltinToolbar(aToolbarId) { + return CustomizableUIInternal._builtinToolbars.has(aToolbarId); + }, + + /** + * Create an instance of a spring, spacer or separator. + * @param aId the type of special widget (spring, spacer or separator) + * @param aDocument the document in which to create it. + */ + createSpecialWidget(aId, aDocument) { + return CustomizableUIInternal.createSpecialWidget(aId, aDocument); + }, + + /** + * Fills a submenu with menu items. + * @param aMenuItems the menu items to display. + * @param aSubview the subview to fill. + */ + fillSubviewFromMenuItems(aMenuItems, aSubview) { + let attrs = [ + "oncommand", + "onclick", + "label", + "key", + "disabled", + "command", + "observes", + "hidden", + "class", + "origin", + "image", + "checked", + "style", + ]; + + // Use ownerGlobal.document to ensure we get the right doc even for + // elements in template tags. + let doc = aSubview.ownerGlobal.document; + let fragment = doc.createDocumentFragment(); + for (let menuChild of aMenuItems) { + if (menuChild.hidden) { + continue; + } + + let subviewItem; + if (menuChild.localName == "menuseparator") { + // Don't insert duplicate or leading separators. This can happen if there are + // menus (which we don't copy) above the separator. + if ( + !fragment.lastElementChild || + fragment.lastElementChild.localName == "toolbarseparator" + ) { + continue; + } + subviewItem = doc.createXULElement("toolbarseparator"); + } else if (menuChild.localName == "menuitem") { + subviewItem = doc.createXULElement("toolbarbutton"); + CustomizableUI.addShortcut(menuChild, subviewItem); + + let item = menuChild; + if (!item.hasAttribute("onclick")) { + subviewItem.addEventListener("click", event => { + let newEvent = new doc.defaultView.MouseEvent(event.type, event); + + // Telemetry should only pay attention to the original event. + lazy.BrowserUsageTelemetry.ignoreEvent(newEvent); + item.dispatchEvent(newEvent); + }); + } + + if (!item.hasAttribute("oncommand")) { + subviewItem.addEventListener("command", event => { + let newEvent = doc.createEvent("XULCommandEvent"); + newEvent.initCommandEvent( + event.type, + event.bubbles, + event.cancelable, + event.view, + event.detail, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + 0, + event.sourceEvent, + 0 + ); + + // Telemetry should only pay attention to the original event. + lazy.BrowserUsageTelemetry.ignoreEvent(newEvent); + item.dispatchEvent(newEvent); + }); + } + } else { + continue; + } + for (let attr of attrs) { + let attrVal = menuChild.getAttribute(attr); + if (attrVal) { + subviewItem.setAttribute(attr, attrVal); + } + } + // We do this after so the .subviewbutton class doesn't get overriden. + if (menuChild.localName == "menuitem") { + subviewItem.classList.add("subviewbutton"); + } + + // We make it possible to supply an alternative Fluent key when cloning + // this menuitem into the AppMenu or panel contexts. This is because + // we often use Title Case in menuitems in native menus, but want to use + // Sentence case in the AppMenu / panels. + let l10nId = menuChild.getAttribute("appmenu-data-l10n-id"); + if (l10nId) { + doc.l10n.setAttributes(subviewItem, l10nId); + } + + fragment.appendChild(subviewItem); + } + aSubview.appendChild(fragment); + }, + + /** + * A helper function for clearing subviews. + * @param aSubview the subview to clear. + */ + clearSubview(aSubview) { + let parent = aSubview.parentNode; + // We'll take the container out of the document before cleaning it out + // to avoid reflowing each time we remove something. + parent.removeChild(aSubview); + + while (aSubview.firstChild) { + aSubview.firstChild.remove(); + } + + parent.appendChild(aSubview); + }, + + getCustomizationTarget(aElement) { + return CustomizableUIInternal.getCustomizationTarget(aElement); + }, + + getTestOnlyInternalProp(aProp) { + if (!Cu.isInAutomation) { + return null; + } + switch (aProp) { + case "CustomizableUIInternal": + return CustomizableUIInternal; + case "gAreas": + return gAreas; + case "gFuturePlacements": + return gFuturePlacements; + case "gPalette": + return gPalette; + case "gPlacements": + return gPlacements; + case "gSavedState": + return gSavedState; + case "gSeenWidgets": + return gSeenWidgets; + case "kVersion": + return kVersion; + } + return null; + }, + setTestOnlyInternalProp(aProp, aValue) { + if (!Cu.isInAutomation) { + return; + } + switch (aProp) { + case "gSavedState": + gSavedState = aValue; + break; + case "kVersion": + kVersion = aValue; + break; + case "gDirty": + gDirty = aValue; + break; + } + }, +}; + +Object.freeze(CustomizableUI); +Object.freeze(CustomizableUI.windows); + +/** + * All external consumers of widgets are really interacting with these wrappers + * which provide a common interface. + */ + +/** + * WidgetGroupWrapper is the common interface for interacting with an entire + * widget group - AKA, all instances of a widget across a series of windows. + * This particular wrapper is only used for widgets created via the provider + * API. + */ +function WidgetGroupWrapper(aWidget) { + this.isGroup = true; + + const kBareProps = [ + "id", + "source", + "type", + "disabled", + "label", + "tooltiptext", + "showInPrivateBrowsing", + "viewId", + "disallowSubView", + "webExtension", + ]; + for (let prop of kBareProps) { + let propertyName = prop; + this.__defineGetter__(propertyName, () => aWidget[propertyName]); + } + + this.__defineGetter__("provider", () => CustomizableUI.PROVIDER_API); + + this.__defineSetter__("disabled", function (aValue) { + aValue = !!aValue; + aWidget.disabled = aValue; + for (let [, instance] of aWidget.instances) { + instance.disabled = aValue; + } + }); + + this.forWindow = function WidgetGroupWrapper_forWindow(aWindow) { + let wrapperMap; + if (!gSingleWrapperCache.has(aWindow)) { + wrapperMap = new Map(); + gSingleWrapperCache.set(aWindow, wrapperMap); + } else { + wrapperMap = gSingleWrapperCache.get(aWindow); + } + if (wrapperMap.has(aWidget.id)) { + return wrapperMap.get(aWidget.id); + } + + let instance = aWidget.instances.get(aWindow.document); + if (!instance) { + instance = CustomizableUIInternal.buildWidget(aWindow.document, aWidget); + } + + let wrapper = new WidgetSingleWrapper(aWidget, instance); + wrapperMap.set(aWidget.id, wrapper); + return wrapper; + }; + + this.__defineGetter__("instances", function () { + // Can't use gBuildWindows here because some areas load lazily: + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidget.id); + if (!placement) { + return []; + } + let area = placement.area; + let buildAreas = gBuildAreas.get(area); + if (!buildAreas) { + return []; + } + return Array.from(buildAreas, node => this.forWindow(node.ownerGlobal)); + }); + + this.__defineGetter__("areaType", function () { + let areaProps = gAreas.get(aWidget.currentArea); + return areaProps && areaProps.get("type"); + }); + + Object.freeze(this); +} + +/** + * A WidgetSingleWrapper is a wrapper around a single instance of a widget in + * a particular window. + */ +function WidgetSingleWrapper(aWidget, aNode) { + this.isGroup = false; + + this.node = aNode; + this.provider = CustomizableUI.PROVIDER_API; + + const kGlobalProps = ["id", "type"]; + for (let prop of kGlobalProps) { + this[prop] = aWidget[prop]; + } + + const kNodeProps = ["label", "tooltiptext"]; + for (let prop of kNodeProps) { + let propertyName = prop; + // Look at the node for these, instead of the widget data, to ensure the + // wrapper always reflects this live instance. + this.__defineGetter__(propertyName, () => aNode.getAttribute(propertyName)); + } + + this.__defineGetter__("disabled", () => aNode.disabled); + this.__defineSetter__("disabled", function (aValue) { + aNode.disabled = !!aValue; + }); + + this.__defineGetter__("anchor", function () { + let anchorId; + // First check for an anchor for the area: + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidget.id); + if (placement) { + anchorId = gAreas.get(placement.area).get("anchor"); + } + if (!anchorId) { + anchorId = aNode.getAttribute("cui-anchorid"); + } + if (!anchorId) { + anchorId = aNode.getAttribute("view-button-id"); + } + if (anchorId) { + return aNode.ownerDocument.getElementById(anchorId); + } + if (aWidget.type == "button-and-view") { + return aNode.lastElementChild; + } + return aNode; + }); + + this.__defineGetter__("overflowed", function () { + return aNode.getAttribute("overflowedItem") == "true"; + }); + + Object.freeze(this); +} + +/** + * XULWidgetGroupWrapper is the common interface for interacting with an entire + * widget group - AKA, all instances of a widget across a series of windows. + * This particular wrapper is only used for widgets created via the old-school + * XUL method (overlays, or programmatically injecting toolbaritems, or other + * such things). + */ +// XXXunf Going to need to hook this up to some events to keep it all live. +function XULWidgetGroupWrapper(aWidgetId) { + this.isGroup = true; + this.id = aWidgetId; + this.type = "custom"; + // XUL Widgets can never be provided by extensions. + this.webExtension = false; + this.provider = CustomizableUI.PROVIDER_XUL; + + this.forWindow = function XULWidgetGroupWrapper_forWindow(aWindow) { + let wrapperMap; + if (!gSingleWrapperCache.has(aWindow)) { + wrapperMap = new Map(); + gSingleWrapperCache.set(aWindow, wrapperMap); + } else { + wrapperMap = gSingleWrapperCache.get(aWindow); + } + if (wrapperMap.has(aWidgetId)) { + return wrapperMap.get(aWidgetId); + } + + let instance = aWindow.document.getElementById(aWidgetId); + if (!instance) { + // Toolbar palettes aren't part of the document, so elements in there + // won't be found via document.getElementById(). + instance = aWindow.gNavToolbox.palette.getElementsByAttribute( + "id", + aWidgetId + )[0]; + } + + let wrapper = new XULWidgetSingleWrapper( + aWidgetId, + instance, + aWindow.document + ); + wrapperMap.set(aWidgetId, wrapper); + return wrapper; + }; + + this.__defineGetter__("areaType", function () { + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId); + if (!placement) { + return null; + } + + let areaProps = gAreas.get(placement.area); + return areaProps && areaProps.get("type"); + }); + + this.__defineGetter__("instances", function () { + return Array.from(gBuildWindows, wins => this.forWindow(wins[0])); + }); + + Object.freeze(this); +} + +/** + * A XULWidgetSingleWrapper is a wrapper around a single instance of a XUL + * widget in a particular window. + */ +function XULWidgetSingleWrapper(aWidgetId, aNode, aDocument) { + this.isGroup = false; + + this.id = aWidgetId; + this.type = "custom"; + this.provider = CustomizableUI.PROVIDER_XUL; + + let weakDoc = Cu.getWeakReference(aDocument); + // If we keep a strong ref, the weak ref will never die, so null it out: + aDocument = null; + + this.__defineGetter__("node", function () { + // If we've set this to null (further down), we're sure there's nothing to + // be gotten here, so bail out early: + if (!weakDoc) { + return null; + } + if (aNode) { + // Return the last known node if it's still in the DOM... + if (aNode.isConnected) { + return aNode; + } + // ... or the toolbox + let toolbox = aNode.ownerGlobal.gNavToolbox; + if (toolbox && toolbox.palette && aNode.parentNode == toolbox.palette) { + return aNode; + } + // If it isn't, clear the cached value and fall through to the "slow" case: + aNode = null; + } + + let doc = weakDoc.get(); + if (doc) { + // Store locally so we can cache the result: + aNode = CustomizableUIInternal.findWidgetInWindow( + aWidgetId, + doc.defaultView + ); + return aNode; + } + // The weakref to the document is dead, we're done here forever more: + weakDoc = null; + return null; + }); + + this.__defineGetter__("anchor", function () { + let anchorId; + // First check for an anchor for the area: + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId); + if (placement) { + anchorId = gAreas.get(placement.area).get("anchor"); + } + + let node = this.node; + if (!anchorId && node) { + anchorId = node.getAttribute("cui-anchorid"); + } + + return anchorId && node + ? node.ownerDocument.getElementById(anchorId) + : node; + }); + + this.__defineGetter__("overflowed", function () { + let node = this.node; + if (!node) { + return false; + } + return node.getAttribute("overflowedItem") == "true"; + }); + + Object.freeze(this); +} + +/** + * OverflowableToolbar is a class that gives a <xul:toolbar> the ability to send + * toolbar items that are "overflowable" to lists in separate panels if and + * when the toolbar shrinks enough so that those items overflow out of bounds. + * Secondly, this class manages moving things out from those panels and back + * into the toolbar once it underflows and has the space to accommodate the + * items that had originally overflowed out. + * + * There are two panels that toolbar items can be overflowed to: + * + * 1. The default items overflow panel + * This is where built-in default toolbar items will go to. + * 2. The Unified Extensions panel + * This is where browser_action toolbar buttons created by extensions will + * go to if the Unified Extensions UI is enabled - otherwise, those items will + * go to the default items overflow panel. + * + * Finally, OverflowableToolbar manages the showing of the default items + * overflow panel when the associated anchor is clicked or dragged over. The + * Unified Extensions panel is managed separately by the extension code. + * + * In theory, we could have multiple overflowable toolbars, but in practice, + * only the nav-bar (CustomizableUI.AREA_NAVBAR) makes use of this class. + */ +class OverflowableToolbar { + /** + * The OverflowableToolbar class is constructed during browser window + * creation, but to optimize for window painting, we defer most work until + * after the window has painted. This property is set to true once + * initialization has completed. + * + * @type {boolean} + */ + #initialized = false; + + /** + * A reference to the <xul:toolbar> that is overflowable. + * + * @type {Element} + */ + #toolbar = null; + + /** + * A reference to the part of the <xul:toolbar> that accepts CustomizableUI + * widgets. + * + * @type {Element} + */ + #target = null; + + /** + * A mapping from the ID of a toolbar item that has overflowed to the width + * that the toolbar item occupied in the toolbar at the time of overflow. Any + * item that is currently overflowed will have an entry in this map. + * + * @type {Map<string, number>} + */ + #overflowedInfo = new Map(); + + /** + * The set of overflowed DOM nodes that were hidden at the time of overflowing. + */ + #hiddenOverflowedNodes = new WeakSet(); + + /** + * True if the overflowable toolbar is actively handling overflows and + * underflows. This value is set internally by the private #enable() and + * #disable() methods. + * + * @type {boolean} + */ + #enabled = true; + + /** + * A reference to the element that overflowed toolbar items will be + * appended to as children upon overflow. + * + * @type {Element} + */ + #defaultList = null; + + /** + * A reference to the button that opens the overflow panel. This is also + * the element that the panel will anchor to. + * + * @type {Element} + */ + #defaultListButton = null; + + /** + * A reference to the <xul:panel> overflow panel that contains the #defaultList + * element. + * + * @type {Element} + */ + #defaultListPanel = null; + + /** + * A reference to the the element that overflowed extension browser action + * toolbar items will be appended to as children upon overflow if the + * Unified Extension UI is enabled. This is created lazily and might be null, + * so you should use the #webExtList memoizing getter instead to get this. + * + * @type {Element|null} + */ + #webExtListRef = null; + + /** + * An empty object that is created in #checkOverflow to identify individual + * calls to #checkOverflow and avoid re-entrancy (since #checkOverflow is + * asynchronous, and in theory, could be called multiple times before any of + * those times have a chance to fully exit). + * + * @type {Object} + */ + #checkOverflowHandle = null; + + /** + * A timeout ID returned by setTimeout that identifies a timeout function that + * runs to hide the #defaultListPanel if the user happened to open the panel by dragging + * over the #defaultListButton and then didn't hover any part of the #defaultListPanel. + * + * @type {number} + */ + #hideTimeoutId = null; + + /** + * Public methods start here. + */ + + /** + * OverflowableToolbar constructor. This is run very early on in the lifecycle + * of a browser window, so it tries to defer most work to the init() method + * instead after first paint. + * + * Upon construction, a "overflowable" attribute will be set on the + * toolbar, set to the value of "true". + * + * Part of the API for OverflowableToolbar is declarative, in that it expects + * certain attributes to be set on the <xul:toolbar> that is overflowable. + * Those attributes are: + * + * default-overflowbutton: + * The ID of the button that is used to open and anchor the overflow panel. + * default-overflowtarget: + * The ID of the element that overflowed items will be appended to as + * children. Note that the overflowed toolbar items are moved into and out + * of this overflow target, so it is definitely advisable to let + * OverflowableToolbar own managing the children of default-overflowtarget, + * and to not modify it outside of this class. + * default-overflowpanel: + * The ID of the <xul:panel> that contains the default-overflowtarget. + * addon-webext-overflowbutton: + * The ID of the button that is used to open and anchor the Unified + * Extensions panel. + * addon-webext-overflowtarget: + * The ID of the element that overflowed extension toolbar buttons will + * be appended to as children if the Unified Extensions UI is enabled. + * Note that the overflowed toolbar items are moved into and out of this + * overflow target, so it is definitely advisable to let OverflowableToolbar + * own managing the children of addon-webext-overflowtarget, and to not + * modify it outside of this class. + * + * @param {Element} aToolbarNode The <xul:toolbar> that will be overflowable. + * @throws {Error} Throws if the customization target of the toolbar somehow + * isn't a direct descendent of the toolbar. + */ + constructor(aToolbarNode) { + this.#toolbar = aToolbarNode; + this.#target = CustomizableUI.getCustomizationTarget(this.#toolbar); + if (this.#target.parentNode != this.#toolbar) { + throw new Error( + "Customization target must be a direct child of an overflowable toolbar." + ); + } + + this.#toolbar.setAttribute("overflowable", "true"); + let doc = this.#toolbar.ownerDocument; + this.#defaultList = doc.getElementById( + this.#toolbar.getAttribute("default-overflowtarget") + ); + this.#defaultList._customizationTarget = this.#defaultList; + + let window = this.#toolbar.ownerGlobal; + + if (window.gBrowserInit.delayedStartupFinished) { + this.init(); + } else { + Services.obs.addObserver(this, "browser-delayed-startup-finished"); + } + } + + /** + * Does final initialization of the OverflowableToolbar after the window has + * first painted. This will also kick off the first check to see if overflow + * has already occurred at the time of initialization. + */ + init() { + let doc = this.#toolbar.ownerDocument; + let window = doc.defaultView; + window.addEventListener("resize", this); + window.gNavToolbox.addEventListener("customizationstarting", this); + window.gNavToolbox.addEventListener("aftercustomization", this); + + let defaultListButton = this.#toolbar.getAttribute( + "default-overflowbutton" + ); + this.#defaultListButton = doc.getElementById(defaultListButton); + this.#defaultListButton.addEventListener("mousedown", this); + this.#defaultListButton.addEventListener("keypress", this); + this.#defaultListButton.addEventListener("dragover", this); + this.#defaultListButton.addEventListener("dragend", this); + + let panelId = this.#toolbar.getAttribute("default-overflowpanel"); + this.#defaultListPanel = doc.getElementById(panelId); + this.#defaultListPanel.addEventListener("popuphiding", this); + CustomizableUIInternal.addPanelCloseListeners(this.#defaultListPanel); + + CustomizableUI.addListener(this); + + this.#checkOverflow(); + + this.#initialized = true; + } + + /** + * Almost the exact reverse of init(). This is called when the browser window + * is unloading. + */ + uninit() { + this.#toolbar.removeAttribute("overflowable"); + + if (!this.#initialized) { + Services.obs.removeObserver(this, "browser-delayed-startup-finished"); + return; + } + + this.#disable(); + + let window = this.#toolbar.ownerGlobal; + window.removeEventListener("resize", this); + window.gNavToolbox.removeEventListener("customizationstarting", this); + window.gNavToolbox.removeEventListener("aftercustomization", this); + this.#defaultListButton.removeEventListener("mousedown", this); + this.#defaultListButton.removeEventListener("keypress", this); + this.#defaultListButton.removeEventListener("dragover", this); + this.#defaultListButton.removeEventListener("dragend", this); + this.#defaultListPanel.removeEventListener("popuphiding", this); + + CustomizableUI.removeListener(this); + CustomizableUIInternal.removePanelCloseListeners(this.#defaultListPanel); + } + + /** + * Opens the overflow #defaultListPanel if it's not already open. If the panel is in + * the midst of hiding when this is called, the panel will be re-opened. + * + * @returns {Promise} + * @resolves {undefined} once the panel is open. + */ + show(aEvent) { + if (this.#defaultListPanel.state == "open") { + return Promise.resolve(); + } + return new Promise(resolve => { + let doc = this.#defaultListPanel.ownerDocument; + this.#defaultListPanel.hidden = false; + let multiview = this.#defaultListPanel.querySelector("panelmultiview"); + let mainViewId = multiview.getAttribute("mainViewId"); + let mainView = doc.getElementById(mainViewId); + let contextMenu = doc.getElementById(mainView.getAttribute("context")); + Services.els.addSystemEventListener(contextMenu, "command", this, true); + let anchor = this.#defaultListButton.icon; + + let popupshown = false; + this.#defaultListPanel.addEventListener( + "popupshown", + () => { + popupshown = true; + this.#defaultListPanel.addEventListener("dragover", this); + this.#defaultListPanel.addEventListener("dragend", this); + // Wait until the next tick to resolve so all popupshown + // handlers have a chance to run before our promise resolution + // handlers do. + Services.tm.dispatchToMainThread(resolve); + }, + { once: true } + ); + + let openPanel = () => { + // Ensure we update the gEditUIVisible flag when opening the popup, in + // case the edit controls are in it. + this.#defaultListPanel.addEventListener( + "popupshowing", + () => { + doc.defaultView.updateEditUIVisibility(); + }, + { once: true } + ); + + this.#defaultListPanel.addEventListener( + "popuphidden", + () => { + if (!popupshown) { + // The panel was hidden again before it was shown. This can break + // consumers waiting for the panel to show. So we try again. + openPanel(); + } + }, + { once: true } + ); + + lazy.PanelMultiView.openPopup( + this.#defaultListPanel, + anchor || this.#defaultListButton, + { + triggerEvent: aEvent, + } + ); + this.#defaultListButton.open = true; + }; + + openPanel(); + }); + } + + /** + * Exposes whether #checkOverflow is currently running. + * + * @returns {boolean} True if #checkOverflow is currently running. + */ + isHandlingOverflow() { + return !!this.#checkOverflowHandle; + } + + /** + * Finds the most appropriate place to insert toolbar item aNode if we've been + * asked to put it into the overflowable toolbar without being told exactly + * where. + * + * @param {Element} aNode The toolbar item being inserted. + * @returns {Array} [parent, nextNode] + * parent: {Element} The parent element that should contain aNode. + * nextNode: {Element|null} The node that should follow aNode after + * insertion, if any. If this is null, aNode should be placed at the end + * of parent. + */ + findOverflowedInsertionPoints(aNode) { + let newNodeCanOverflow = aNode.getAttribute("overflows") != "false"; + let areaId = this.#toolbar.id; + let placements = gPlacements.get(areaId); + let nodeIndex = placements.indexOf(aNode.id); + let nodeBeforeNewNodeIsOverflown = false; + + let loopIndex = -1; + // Loop through placements to find where to insert this item. + // As soon as we find an overflown widget, we will only + // insert in the overflow panel (this is why we check placements + // before the desired location for the new node). Once we pass + // the desired location of the widget, we look for placement ids + // that actually have DOM equivalents to insert before. If all + // else fails, we insert at the end of either the overflow list + // or the toolbar target. + while (++loopIndex < placements.length) { + let nextNodeId = placements[loopIndex]; + if (loopIndex > nodeIndex) { + // Note that if aNode is in a template, its `ownerDocument` is *not* + // going to be the browser.xhtml document, so we cannot rely on it. + let nextNode = this.#toolbar.ownerDocument.getElementById(nextNodeId); + // If the node we're inserting can overflow, and the next node + // in the toolbar is overflown, we should insert this node + // in the overflow panel before it. + if ( + newNodeCanOverflow && + this.#overflowedInfo.has(nextNodeId) && + nextNode && + nextNode.parentNode == this.#defaultList + ) { + return [this.#defaultList, nextNode]; + } + // Otherwise (if either we can't overflow, or the previous node + // wasn't overflown), and the next node is in the toolbar itself, + // insert the node in the toolbar. + if ( + (!nodeBeforeNewNodeIsOverflown || !newNodeCanOverflow) && + nextNode && + (nextNode.parentNode == this.#target || + // Also check if the next node is in a customization wrapper + // (toolbarpaletteitem). We don't need to do this for the + // overflow case because overflow is disabled in customize mode. + (nextNode.parentNode.localName == "toolbarpaletteitem" && + nextNode.parentNode.parentNode == this.#target)) + ) { + return [this.#target, nextNode]; + } + } else if ( + loopIndex < nodeIndex && + this.#overflowedInfo.has(nextNodeId) + ) { + nodeBeforeNewNodeIsOverflown = true; + } + } + + let overflowList = CustomizableUI.isWebExtensionWidget(aNode.id) + ? this.#webExtList + : this.#defaultList; + + let containerForAppending = + this.#overflowedInfo.size && newNodeCanOverflow + ? overflowList + : this.#target; + return [containerForAppending, null]; + } + + /** + * Allows callers to query for the current parent of a toolbar item that may + * or may not be overflowed. That parent will either be #defaultList, + * #webExtList (if it's an extension button) or #target. + * + * Note: It is assumed that the caller has verified that aNode is placed + * within the toolbar customizable area according to CustomizableUI. + * + * @param {Element} aNode the node that can be overflowed by this + * OverflowableToolbar. + * @returns {Element} The current containing node for aNode. + */ + getContainerFor(aNode) { + if (aNode.getAttribute("overflowedItem") == "true") { + return CustomizableUI.isWebExtensionWidget(aNode.id) + ? this.#webExtList + : this.#defaultList; + } + return this.#target; + } + + /** + * Private methods start here. + */ + + /** + * Handle overflow in the toolbar by moving items to the overflow menu. + */ + async #onOverflow() { + if (!this.#enabled) { + return; + } + + let win = this.#target.ownerGlobal; + let checkOverflowHandle = this.#checkOverflowHandle; + let webExtButtonID = this.#toolbar.getAttribute( + "addon-webext-overflowbutton" + ); + + let { isOverflowing, targetContentWidth } = await this.#getOverflowInfo(); + + // Stop if the window has closed or if we re-enter while waiting for + // layout. + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + lazy.log.debug("Window closed or another overflow handler started."); + return; + } + + let webExtList = this.#webExtList; + + let child = this.#target.lastElementChild; + while (child && isOverflowing) { + let prevChild = child.previousElementSibling; + + if (child.getAttribute("overflows") != "false") { + this.#overflowedInfo.set(child.id, targetContentWidth); + let { width: childWidth } = + win.windowUtils.getBoundsWithoutFlushing(child); + if (!childWidth) { + this.#hiddenOverflowedNodes.add(child); + } + + child.setAttribute("overflowedItem", true); + CustomizableUIInternal.ensureButtonContextMenu( + child, + this.#toolbar, + true + ); + CustomizableUIInternal.notifyListeners( + "onWidgetOverflow", + child, + this.#target + ); + + if (webExtList && CustomizableUI.isWebExtensionWidget(child.id)) { + child.setAttribute("cui-anchorid", webExtButtonID); + webExtList.insertBefore(child, webExtList.firstElementChild); + } else { + child.setAttribute("cui-anchorid", this.#defaultListButton.id); + this.#defaultList.insertBefore( + child, + this.#defaultList.firstElementChild + ); + if (!CustomizableUI.isSpecialWidget(child.id) && childWidth) { + this.#toolbar.setAttribute("overflowing", "true"); + } + } + } + child = prevChild; + ({ isOverflowing, targetContentWidth } = await this.#getOverflowInfo()); + // Stop if the window has closed or if we re-enter while waiting for + // layout. + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + lazy.log.debug("Window closed or another overflow handler started."); + return; + } + } + + win.UpdateUrlbarSearchSplitterState(); + } + + /** + * Returns a Promise that resolves to a an object that describes the state + * that this OverflowableToolbar is currently in. + * + * @returns {Promise} + * @resolves {Object} + * An object with the following properties: + * + * isOverflowing: {boolean} True if at least one toolbar item has overflowed + * into an overflow panel. + * targetContentWidth: {number} The total width of the items within the + * customization target area of the toolbar. + * totalAvailWidth: {number} The maximum width items in the toolbar may + * occupy before causing an overflow. + */ + async #getOverflowInfo() { + function getInlineSize(aElement) { + return aElement.getBoundingClientRect().width; + } + + function sumChildrenInlineSize(aParent, aExceptChild = null) { + let sum = 0; + for (let child of aParent.children) { + let style = win.getComputedStyle(child); + if ( + style.display == "none" || + win.XULPopupElement.isInstance(child) || + (style.position != "static" && style.position != "relative") + ) { + continue; + } + sum += parseFloat(style.marginLeft) + parseFloat(style.marginRight); + if (child != aExceptChild) { + sum += getInlineSize(child); + } + } + return sum; + } + + let win = this.#target.ownerGlobal; + let totalAvailWidth; + let targetWidth; + let targetChildrenWidth; + + await win.promiseDocumentFlushed(() => { + let style = win.getComputedStyle(this.#toolbar); + let toolbarChildrenWidth = sumChildrenInlineSize( + this.#toolbar, + this.#target + ); + totalAvailWidth = + getInlineSize(this.#toolbar) - + parseFloat(style.paddingLeft) - + parseFloat(style.paddingRight) - + toolbarChildrenWidth; + targetWidth = getInlineSize(this.#target); + targetChildrenWidth = + this.#target == this.#toolbar + ? toolbarChildrenWidth + : sumChildrenInlineSize(this.#target); + }); + + lazy.log.debug( + `Getting overflow info: target width: ${targetWidth} (${targetChildrenWidth}); avail: ${totalAvailWidth}` + ); + + // If the target has min-width: 0, their children might actually overflow + // it, so check for both cases explicitly. + let targetContentWidth = Math.max(targetWidth, targetChildrenWidth); + let isOverflowing = Math.floor(targetContentWidth) > totalAvailWidth; + return { isOverflowing, targetContentWidth, totalAvailWidth }; + } + + /** + * Tries to move toolbar items back to the toolbar from the overflow panel. + * + * @param {boolean} shouldMoveAllItems + * Whether we should move everything (e.g. because we're being + * disabled) + * @param {number} [totalAvailWidth=undefined] + * Optional; the width of the toolbar area in which we can put things. + * Some consumers pass this to avoid reflows. + * + * While there are items in the list, this width won't change, and so + * we can avoid flushing layout by providing it and/or caching it. + * Note that if `shouldMoveAllItems` is true, we never need the width + * anyway, and this value is ignored. + * @returns {Promise} + * @resolves {undefined} Once moving of items has completed. + */ + async #moveItemsBackToTheirOrigin(shouldMoveAllItems, totalAvailWidth) { + lazy.log.debug( + `Attempting to move ${shouldMoveAllItems ? "all" : "some"} items back` + ); + let placements = gPlacements.get(this.#toolbar.id); + let win = this.#target.ownerGlobal; + let doc = this.#target.ownerDocument; + let checkOverflowHandle = this.#checkOverflowHandle; + + let overflowedItemStack = Array.from(this.#overflowedInfo.entries()); + + for (let i = overflowedItemStack.length - 1; i >= 0; --i) { + let [childID, minSize] = overflowedItemStack[i]; + + // The item may have been placed inside of a <xul:panel> that is lazily + // loaded and still in the view cache. PanelMultiView.getViewNode will + // do the work of checking the DOM for the child, and then falling back to + // the cache if that is the case. + let child = lazy.PanelMultiView.getViewNode(doc, childID); + + if (!child) { + this.#overflowedInfo.delete(childID); + continue; + } + + lazy.log.debug( + `Considering moving ${child.id} back, minSize: ${minSize}` + ); + + if (!shouldMoveAllItems && minSize) { + if (!totalAvailWidth) { + ({ totalAvailWidth } = await this.#getOverflowInfo()); + + // If the window has closed or if we re-enter because we were waiting + // for layout, stop. + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + lazy.log.debug("Window closed or #checkOverflow called again."); + return; + } + } + if (totalAvailWidth <= minSize) { + lazy.log.debug( + `Need ${minSize} but width is ${totalAvailWidth} so bailing` + ); + break; + } + } + + lazy.log.debug(`Moving ${child.id} back`); + this.#overflowedInfo.delete(child.id); + let beforeNodeIndex = placements.indexOf(child.id) + 1; + // If this is a skipintoolbarset item, meaning it doesn't occur in the placements list, + // we're inserting it at the end. This will mean first-in, first-out (more or less) + // leading to as little change in order as possible. + if (beforeNodeIndex == 0) { + beforeNodeIndex = placements.length; + } + let inserted = false; + for (; beforeNodeIndex < placements.length; beforeNodeIndex++) { + let beforeNode = this.#target.getElementsByAttribute( + "id", + placements[beforeNodeIndex] + )[0]; + // Unfortunately, XUL add-ons can mess with nodes after they are inserted, + // and this breaks the following code if the button isn't where we expect + // it to be (ie not a child of the target). In this case, ignore the node. + if (beforeNode && this.#target == beforeNode.parentElement) { + this.#target.insertBefore(child, beforeNode); + inserted = true; + break; + } + } + if (!inserted) { + this.#target.appendChild(child); + } + child.removeAttribute("cui-anchorid"); + child.removeAttribute("overflowedItem"); + CustomizableUIInternal.ensureButtonContextMenu(child, this.#target); + CustomizableUIInternal.notifyListeners( + "onWidgetUnderflow", + child, + this.#target + ); + } + + win.UpdateUrlbarSearchSplitterState(); + + let defaultListItems = Array.from(this.#defaultList.children); + if ( + defaultListItems.every( + item => + CustomizableUI.isSpecialWidget(item.id) || + this.#hiddenOverflowedNodes.has(item) + ) + ) { + this.#toolbar.removeAttribute("overflowing"); + } + } + + /** + * Checks to see if there are overflowable items within the customization + * target of the toolbar that should be moved into the overflow panel, and + * if there are, moves them. + * + * Note that since this is an async function that can be called in bursts + * by resize events on the window, this function is often re-called even + * when a prior call hasn't yet resolved. In that situation, the older calls + * resolve early without doing any work and leave any DOM manipulation to the + * most recent call. + * + * This function is a no-op if the OverflowableToolbar is disabled or the + * DOM fullscreen UI is currently being used. + * + * @returns {Promise} + * @resolves {undefined} Once any movement of toolbar items has completed. + */ + async #checkOverflow() { + if (!this.#enabled) { + return; + } + + let win = this.#target.ownerGlobal; + if (win.document.documentElement.hasAttribute("inDOMFullscreen")) { + // Toolbars are hidden and cannot be made visible in DOM fullscreen mode + // so there's nothing to do here. + return; + } + + let checkOverflowHandle = (this.#checkOverflowHandle = {}); + + lazy.log.debug("Checking overflow"); + let { isOverflowing, totalAvailWidth } = await this.#getOverflowInfo(); + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + return; + } + + if (isOverflowing) { + await this.#onOverflow(); + } else { + await this.#moveItemsBackToTheirOrigin(false, totalAvailWidth); + } + + if (checkOverflowHandle == this.#checkOverflowHandle) { + this.#checkOverflowHandle = null; + } + } + + /** + * Makes the OverflowableToolbar inert and moves all overflowable items back + * into the customization target of the toolbar. + */ + #disable() { + // Abort any ongoing overflow check. #enable() will #checkOverflow() + // anyways, so this is enough. + this.#checkOverflowHandle = {}; + this.#moveItemsBackToTheirOrigin(true); + this.#enabled = false; + } + + /** + * Puts the OverflowableToolbar into the enabled state and then checks to see + * if any of the items in the customization target should be overflowed into + * the overflow panel list. + */ + #enable() { + this.#enabled = true; + this.#checkOverflow(); + } + + /** + * Shows the overflow panel and sets a timeout to automatically re-hide the + * panel if it is not being hovered. + */ + #showWithTimeout() { + const OVERFLOW_PANEL_HIDE_DELAY_MS = 500; + + this.show().then(() => { + let window = this.#toolbar.ownerGlobal; + if (this.#hideTimeoutId) { + window.clearTimeout(this.#hideTimeoutId); + } + this.#hideTimeoutId = window.setTimeout(() => { + if (!this.#defaultListPanel.firstElementChild.matches(":hover")) { + lazy.PanelMultiView.hidePopup(this.#defaultListPanel); + } + }, OVERFLOW_PANEL_HIDE_DELAY_MS); + }); + } + + /** + * Gets and caches a reference to the DOM node with the ID set as the value + * of addon-webext-overflowtarget. If a cache already exists, that's returned + * instead. If addon-webext-overflowtarget has no value, null is returned. + * + * @returns {Element|null} the list that overflowed extension toolbar + * buttons should go to if the Unified Extensions UI is enabled, or null + * if no such list exists. + */ + get #webExtList() { + if (!this.#webExtListRef) { + let targetID = this.#toolbar.getAttribute("addon-webext-overflowtarget"); + if (!targetID) { + throw new Error( + "addon-webext-overflowtarget was not defined on the " + + `overflowable toolbar with id: ${this.#toolbar.id}` + ); + } + let win = this.#toolbar.ownerGlobal; + let { panel } = win.gUnifiedExtensions; + this.#webExtListRef = panel.querySelector(`#${targetID}`); + } + return this.#webExtListRef; + } + + /** + * Returns true if aNode is not null and is one of either this.#webExtList or + * this.#defaultList. + * + * @param {DOMElement} aNode The node to test. + * @returns {boolean} + */ + #isOverflowList(aNode) { + return aNode == this.#defaultList || aNode == this.#webExtList; + } + + /** + * Private event handlers start here. + */ + + /** + * Handles clicks on the #defaultListButton element. + * + * @param {MouseEvent} aEvent the click event. + */ + #onClickDefaultListButton(aEvent) { + if (this.#defaultListButton.open) { + this.#defaultListButton.open = false; + lazy.PanelMultiView.hidePopup(this.#defaultListPanel); + } else if ( + this.#defaultListPanel.state != "hiding" && + !this.#defaultListButton.disabled + ) { + this.show(aEvent); + } + } + + /** + * Handles the popuphiding event firing on the #defaultListPanel. + * + * @param {WidgetMouseEvent} aEvent the popuphiding event that fired on the + * #defaultListPanel. + */ + #onPanelHiding(aEvent) { + if (aEvent.target != this.#defaultListPanel) { + // Ignore context menus, <select> popups, etc. + return; + } + this.#defaultListButton.open = false; + this.#defaultListPanel.removeEventListener("dragover", this); + this.#defaultListPanel.removeEventListener("dragend", this); + let doc = aEvent.target.ownerDocument; + doc.defaultView.updateEditUIVisibility(); + let contextMenuId = this.#defaultListPanel.getAttribute("context"); + if (contextMenuId) { + let contextMenu = doc.getElementById(contextMenuId); + Services.els.removeSystemEventListener( + contextMenu, + "command", + this, + true + ); + } + } + + /** + * Handles a resize event fired on the window hosting this + * OverflowableToolbar. + * + * @param {UIEvent} aEvent the resize event. + */ + #onResize(aEvent) { + // Ignore bubbled-up resize events. + if (aEvent.target != aEvent.currentTarget) { + return; + } + this.#checkOverflow(); + } + + /** + * CustomizableUI listener methods start here. + */ + + onWidgetBeforeDOMChange(aNode, aNextNode, aContainer) { + // This listener method is used to handle the case where a widget is + // moved or removed from an area via the CustomizableUI API while + // overflowed. It reorganizes the internal state of this OverflowableToolbar + // to handle that change. + if (!this.#enabled || !this.#isOverflowList(aContainer)) { + return; + } + // When we (re)move an item, update all the items that come after it in the list + // with the minsize *of the item before the to-be-removed node*. This way, we + // ensure that we try to move items back as soon as that's possible. + let updatedMinSize; + if (aNode.previousElementSibling) { + updatedMinSize = this.#overflowedInfo.get( + aNode.previousElementSibling.id + ); + } else { + // Force (these) items to try to flow back into the bar: + updatedMinSize = 1; + } + let nextItem = aNode.nextElementSibling; + while (nextItem) { + this.#overflowedInfo.set(nextItem.id, updatedMinSize); + nextItem = nextItem.nextElementSibling; + } + } + + onWidgetAfterDOMChange(aNode, aNextNode, aContainer) { + // This listener method is used to handle the case where a widget is + // moved or removed from an area via the CustomizableUI API while + // overflowed. It updates the DOM in the event that the movement or removal + // causes overflow or underflow of the toolbar. + if ( + !this.#enabled || + (aContainer != this.#target && !this.#isOverflowList(aContainer)) + ) { + return; + } + + let nowOverflowed = this.#isOverflowList(aNode.parentNode); + let wasOverflowed = this.#overflowedInfo.has(aNode.id); + + // If this wasn't overflowed before... + if (!wasOverflowed) { + // ... but it is now, then we added to one of the overflow panels. + if (nowOverflowed) { + // We could be the first item in the overflow panel if we're being inserted + // before the previous first item in it. We can't assume the minimum + // size is the same (because the other item might be much wider), so if + // there is no previous item, just allow this item to be put back in the + // toolbar immediately by specifying a very low minimum size. + let sourceOfMinSize = aNode.previousElementSibling; + let minSize = sourceOfMinSize + ? this.#overflowedInfo.get(sourceOfMinSize.id) + : 1; + this.#overflowedInfo.set(aNode.id, minSize); + aNode.setAttribute("cui-anchorid", this.#defaultListButton.id); + aNode.setAttribute("overflowedItem", true); + CustomizableUIInternal.ensureButtonContextMenu(aNode, aContainer, true); + CustomizableUIInternal.notifyListeners( + "onWidgetOverflow", + aNode, + this.#target + ); + } + } else if (!nowOverflowed) { + // If it used to be overflowed... + // ... and isn't anymore, let's remove our bookkeeping: + this.#overflowedInfo.delete(aNode.id); + aNode.removeAttribute("cui-anchorid"); + aNode.removeAttribute("overflowedItem"); + CustomizableUIInternal.ensureButtonContextMenu(aNode, aContainer); + CustomizableUIInternal.notifyListeners( + "onWidgetUnderflow", + aNode, + this.#target + ); + + let collapsedWidgetIds = Array.from(this.#overflowedInfo.keys()); + if (collapsedWidgetIds.every(w => CustomizableUI.isSpecialWidget(w))) { + this.#toolbar.removeAttribute("overflowing"); + } + } else if (aNode.previousElementSibling) { + // but if it still is, it must have changed places. Bookkeep: + let prevId = aNode.previousElementSibling.id; + let minSize = this.#overflowedInfo.get(prevId); + this.#overflowedInfo.set(aNode.id, minSize); + } + + // We might overflow now if an item was added, or we may be able to move + // stuff back into the toolbar if an item was removed. + this.#checkOverflow(); + } + + /** + * @returns {Boolean} whether the given node is in the overflow list. + */ + isInOverflowList(node) { + return node.parentNode == this.#defaultList; + } + + /** + * nsIObserver implementation starts here. + */ + + observe(aSubject, aTopic, aData) { + // This nsIObserver method allows us to defer initialization until after + // this window has finished painting and starting up. + if ( + aTopic == "browser-delayed-startup-finished" && + aSubject == this.#toolbar.ownerGlobal + ) { + Services.obs.removeObserver(this, "browser-delayed-startup-finished"); + this.init(); + } + } + + /** + * nsIDOMEventListener implementation starts here. + */ + + handleEvent(aEvent) { + switch (aEvent.type) { + case "aftercustomization": { + this.#enable(); + break; + } + case "mousedown": { + if (aEvent.button != 0) { + break; + } + if (aEvent.target == this.#defaultListButton) { + this.#onClickDefaultListButton(aEvent); + } else { + lazy.PanelMultiView.hidePopup(this.#defaultListPanel); + } + break; + } + case "keypress": { + if ( + aEvent.target == this.#defaultListButton && + (aEvent.key == " " || aEvent.key == "Enter") + ) { + this.#onClickDefaultListButton(aEvent); + } + break; + } + case "customizationstarting": { + this.#disable(); + break; + } + case "dragover": { + if (this.#enabled) { + this.#showWithTimeout(); + } + break; + } + case "dragend": { + lazy.PanelMultiView.hidePopup(this.#defaultListPanel); + break; + } + case "popuphiding": { + this.#onPanelHiding(aEvent); + break; + } + case "resize": { + this.#onResize(aEvent); + break; + } + } + } +} + +CustomizableUIInternal.initialize(); diff --git a/browser/components/customizableui/CustomizableWidgets.sys.mjs b/browser/components/customizableui/CustomizableWidgets.sys.mjs new file mode 100644 index 0000000000..ab95e8e7db --- /dev/null +++ b/browser/components/customizableui/CustomizableWidgets.sys.mjs @@ -0,0 +1,615 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + RecentlyClosedTabsAndWindowsMenuUtils: + "resource:///modules/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs", + Sanitizer: "resource:///modules/Sanitizer.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +const kPrefCustomizationDebug = "browser.uiCustomization.debug"; +const kPrefScreenshots = "extensions.screenshots.disabled"; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let debug = Services.prefs.getBoolPref(kPrefCustomizationDebug, false); + let consoleOptions = { + maxLogLevel: debug ? "all" : "log", + prefix: "CustomizableWidgets", + }; + return new ConsoleAPI(consoleOptions); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "screenshotsDisabled", + kPrefScreenshots, + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SCREENSHOT_BROWSER_COMPONENT", + "screenshots.browser.component.enabled", + false +); + +function setAttributes(aNode, aAttrs) { + let doc = aNode.ownerDocument; + for (let [name, value] of Object.entries(aAttrs)) { + if (!value) { + if (aNode.hasAttribute(name)) { + aNode.removeAttribute(name); + } + } else { + if (name == "shortcutId") { + continue; + } + if (name == "label" || name == "tooltiptext") { + let stringId = typeof value == "string" ? value : name; + let additionalArgs = []; + if (aAttrs.shortcutId) { + let shortcut = doc.getElementById(aAttrs.shortcutId); + if (shortcut) { + additionalArgs.push(lazy.ShortcutUtils.prettifyShortcut(shortcut)); + } + } + value = lazy.CustomizableUI.getLocalizedProperty( + { id: aAttrs.id }, + stringId, + additionalArgs + ); + } + aNode.setAttribute(name, value); + } + } +} + +export const CustomizableWidgets = [ + { + id: "history-panelmenu", + type: "view", + viewId: "PanelUI-history", + shortcutId: "key_gotoHistory", + tooltiptext: "history-panelmenu.tooltiptext2", + recentlyClosedTabsPanel: "appMenu-library-recentlyClosedTabs", + recentlyClosedWindowsPanel: "appMenu-library-recentlyClosedWindows", + handleEvent(event) { + switch (event.type) { + case "PanelMultiViewHidden": + this.onPanelMultiViewHidden(event); + break; + case "ViewShowing": + this.onSubViewShowing(event); + break; + case "unload": + this.onWindowUnload(event); + break; + default: + throw new Error(`Unsupported event for '${this.id}'`); + } + }, + onViewShowing(event) { + if (this._panelMenuView) { + return; + } + + let panelview = event.target; + let document = panelview.ownerDocument; + let window = document.defaultView; + const closedTabCount = lazy.SessionStore.getClosedTabCount(); + + lazy.PanelMultiView.getViewNode( + document, + "appMenuRecentlyClosedTabs" + ).disabled = closedTabCount == 0; + lazy.PanelMultiView.getViewNode( + document, + "appMenuRecentlyClosedWindows" + ).disabled = lazy.SessionStore.getClosedWindowCount(window) == 0; + + lazy.PanelMultiView.getViewNode( + document, + "appMenu-restoreSession" + ).hidden = !lazy.SessionStore.canRestoreLastSession; + + // We restrict the amount of results to 42. Not 50, but 42. Why? Because 42. + let query = + "place:queryType=" + + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING + + "&maxResults=42&excludeQueries=1"; + + this._panelMenuView = new window.PlacesPanelview( + query, + document.getElementById("appMenu_historyMenu"), + panelview + ); + // When either of these sub-subviews show, populate them with recently closed + // objects data. + lazy.PanelMultiView.getViewNode( + document, + this.recentlyClosedTabsPanel + ).addEventListener("ViewShowing", this); + lazy.PanelMultiView.getViewNode( + document, + this.recentlyClosedWindowsPanel + ).addEventListener("ViewShowing", this); + // When the popup is hidden (thus the panelmultiview node as well), make + // sure to stop listening to PlacesDatabase updates. + panelview.panelMultiView.addEventListener("PanelMultiViewHidden", this); + window.addEventListener("unload", this); + }, + onViewHiding(event) { + lazy.log.debug("History view is being hidden!"); + }, + onPanelMultiViewHidden(event) { + let panelMultiView = event.target; + let document = panelMultiView.ownerDocument; + if (this._panelMenuView) { + this._panelMenuView.uninit(); + delete this._panelMenuView; + lazy.PanelMultiView.getViewNode( + document, + this.recentlyClosedTabsPanel + ).removeEventListener("ViewShowing", this); + lazy.PanelMultiView.getViewNode( + document, + this.recentlyClosedWindowsPanel + ).removeEventListener("ViewShowing", this); + } + panelMultiView.removeEventListener("PanelMultiViewHidden", this); + }, + onWindowUnload(event) { + if (this._panelMenuView) { + delete this._panelMenuView; + } + }, + onSubViewShowing(event) { + let panelview = event.target; + let document = event.target.ownerDocument; + let window = document.defaultView; + + this._panelMenuView.clearAllContents(panelview); + + const utils = lazy.RecentlyClosedTabsAndWindowsMenuUtils; + const fragment = + panelview.id == this.recentlyClosedTabsPanel + ? utils.getTabsFragment(window, "toolbarbutton", true) + : utils.getWindowsFragment(window, "toolbarbutton", true); + let elementCount = fragment.childElementCount; + this._panelMenuView._setEmptyPopupStatus(panelview, !elementCount); + if (!elementCount) { + return; + } + + let body = document.createXULElement("vbox"); + body.className = "panel-subview-body"; + body.appendChild(fragment); + let separator = document.createXULElement("toolbarseparator"); + let footer; + while (--elementCount >= 0) { + let element = body.children[elementCount]; + lazy.CustomizableUI.addShortcut(element); + element.classList.add("subviewbutton"); + if (element.classList.contains("restoreallitem")) { + footer = element; + element.classList.add("panel-subview-footer-button"); + } else { + element.classList.add("subviewbutton-iconic", "bookmark-item"); + } + } + panelview.appendChild(body); + panelview.appendChild(separator); + panelview.appendChild(footer); + }, + }, + { + id: "save-page-button", + l10nId: "toolbar-button-save-page", + shortcutId: "key_savePage", + onCreated(aNode) { + aNode.setAttribute("command", "Browser:SavePage"); + }, + }, + { + id: "print-button", + l10nId: "navbar-print", + shortcutId: "printKb", + keepBroadcastAttributesWhenCustomizing: true, + onCreated(aNode) { + aNode.setAttribute("command", "cmd_printPreviewToggle"); + }, + }, + { + id: "find-button", + shortcutId: "key_find", + tooltiptext: "find-button.tooltiptext3", + onCommand(aEvent) { + let win = aEvent.target.ownerGlobal; + if (win.gLazyFindCommand) { + win.gLazyFindCommand("onFindCommand"); + } + }, + }, + { + id: "open-file-button", + l10nId: "toolbar-button-open-file", + shortcutId: "openFileKb", + onCreated(aNode) { + aNode.setAttribute("command", "Browser:OpenFile"); + }, + }, + { + id: "sidebar-button", + tooltiptext: "sidebar-button.tooltiptext2", + onCommand(aEvent) { + let win = aEvent.target.ownerGlobal; + win.SidebarUI.toggle(); + }, + onCreated(aNode) { + // Add an observer so the button is checked while the sidebar is open + let doc = aNode.ownerDocument; + let obChecked = doc.createXULElement("observes"); + obChecked.setAttribute("element", "sidebar-box"); + obChecked.setAttribute("attribute", "checked"); + let obPosition = doc.createXULElement("observes"); + obPosition.setAttribute("element", "sidebar-box"); + obPosition.setAttribute("attribute", "positionend"); + + aNode.appendChild(obChecked); + aNode.appendChild(obPosition); + }, + }, + { + id: "zoom-controls", + type: "custom", + tooltiptext: "zoom-controls.tooltiptext2", + onBuild(aDocument) { + let buttons = [ + { + id: "zoom-out-button", + command: "cmd_fullZoomReduce", + label: true, + closemenu: "none", + tooltiptext: "tooltiptext2", + shortcutId: "key_fullZoomReduce", + class: "toolbarbutton-1 toolbarbutton-combined", + }, + { + id: "zoom-reset-button", + command: "cmd_fullZoomReset", + closemenu: "none", + tooltiptext: "tooltiptext2", + shortcutId: "key_fullZoomReset", + class: "toolbarbutton-1 toolbarbutton-combined", + }, + { + id: "zoom-in-button", + command: "cmd_fullZoomEnlarge", + closemenu: "none", + label: true, + tooltiptext: "tooltiptext2", + shortcutId: "key_fullZoomEnlarge", + class: "toolbarbutton-1 toolbarbutton-combined", + }, + ]; + + let node = aDocument.createXULElement("toolbaritem"); + node.setAttribute("id", "zoom-controls"); + node.setAttribute( + "label", + lazy.CustomizableUI.getLocalizedProperty(this, "label") + ); + node.setAttribute( + "title", + lazy.CustomizableUI.getLocalizedProperty(this, "tooltiptext") + ); + // Set this as an attribute in addition to the property to make sure we can style correctly. + node.setAttribute("removable", "true"); + node.classList.add("chromeclass-toolbar-additional"); + node.classList.add("toolbaritem-combined-buttons"); + + buttons.forEach(function (aButton, aIndex) { + if (aIndex != 0) { + node.appendChild(aDocument.createXULElement("separator")); + } + let btnNode = aDocument.createXULElement("toolbarbutton"); + setAttributes(btnNode, aButton); + node.appendChild(btnNode); + }); + return node; + }, + }, + { + id: "edit-controls", + type: "custom", + tooltiptext: "edit-controls.tooltiptext2", + onBuild(aDocument) { + let buttons = [ + { + id: "cut-button", + command: "cmd_cut", + label: true, + tooltiptext: "tooltiptext2", + shortcutId: "key_cut", + class: "toolbarbutton-1 toolbarbutton-combined", + }, + { + id: "copy-button", + command: "cmd_copy", + label: true, + tooltiptext: "tooltiptext2", + shortcutId: "key_copy", + class: "toolbarbutton-1 toolbarbutton-combined", + }, + { + id: "paste-button", + command: "cmd_paste", + label: true, + tooltiptext: "tooltiptext2", + shortcutId: "key_paste", + class: "toolbarbutton-1 toolbarbutton-combined", + }, + ]; + + let node = aDocument.createXULElement("toolbaritem"); + node.setAttribute("id", "edit-controls"); + node.setAttribute( + "label", + lazy.CustomizableUI.getLocalizedProperty(this, "label") + ); + node.setAttribute( + "title", + lazy.CustomizableUI.getLocalizedProperty(this, "tooltiptext") + ); + // Set this as an attribute in addition to the property to make sure we can style correctly. + node.setAttribute("removable", "true"); + node.classList.add("chromeclass-toolbar-additional"); + node.classList.add("toolbaritem-combined-buttons"); + + buttons.forEach(function (aButton, aIndex) { + if (aIndex != 0) { + node.appendChild(aDocument.createXULElement("separator")); + } + let btnNode = aDocument.createXULElement("toolbarbutton"); + setAttributes(btnNode, aButton); + node.appendChild(btnNode); + }); + + let listener = { + onWidgetInstanceRemoved: (aWidgetId, aDoc) => { + if (aWidgetId != this.id || aDoc != aDocument) { + return; + } + lazy.CustomizableUI.removeListener(listener); + }, + onWidgetOverflow(aWidgetNode) { + if (aWidgetNode == node) { + node.ownerGlobal.updateEditUIVisibility(); + } + }, + onWidgetUnderflow(aWidgetNode) { + if (aWidgetNode == node) { + node.ownerGlobal.updateEditUIVisibility(); + } + }, + }; + lazy.CustomizableUI.addListener(listener); + + return node; + }, + }, + { + id: "characterencoding-button", + l10nId: "repair-text-encoding-button", + onCommand(aEvent) { + aEvent.view.BrowserForceEncodingDetection(); + }, + }, + { + id: "email-link-button", + l10nId: "toolbar-button-email-link", + onCommand(aEvent) { + let win = aEvent.view; + win.MailIntegration.sendLinkForBrowser(win.gBrowser.selectedBrowser); + }, + }, + { + id: "logins-button", + l10nId: "toolbar-button-logins", + onCommand(aEvent) { + let window = aEvent.view; + lazy.LoginHelper.openPasswordManager(window, { entryPoint: "toolbar" }); + }, + }, +]; + +if (Services.prefs.getBoolPref("identity.fxaccounts.enabled")) { + CustomizableWidgets.push({ + id: "sync-button", + l10nId: "toolbar-button-synced-tabs", + type: "view", + viewId: "PanelUI-remotetabs", + onViewShowing(aEvent) { + let panelview = aEvent.target; + let doc = panelview.ownerDocument; + + let syncNowBtn = panelview.querySelector(".syncnow-label"); + let l10nId = syncNowBtn.getAttribute( + panelview.ownerGlobal.gSync._isCurrentlySyncing + ? "syncing-data-l10n-id" + : "sync-now-data-l10n-id" + ); + doc.l10n.setAttributes(syncNowBtn, l10nId); + + let SyncedTabsPanelList = doc.defaultView.SyncedTabsPanelList; + panelview.syncedTabsPanelList = new SyncedTabsPanelList( + panelview, + lazy.PanelMultiView.getViewNode(doc, "PanelUI-remotetabs-deck"), + lazy.PanelMultiView.getViewNode(doc, "PanelUI-remotetabs-tabslist") + ); + }, + onViewHiding(aEvent) { + aEvent.target.syncedTabsPanelList.destroy(); + aEvent.target.syncedTabsPanelList = null; + }, + }); +} + +if (!lazy.screenshotsDisabled) { + CustomizableWidgets.push({ + id: "screenshot-button", + shortcutId: "key_screenshot", + l10nId: "screenshot-toolbarbutton", + onCommand(aEvent) { + if (lazy.SCREENSHOT_BROWSER_COMPONENT) { + Services.obs.notifyObservers( + aEvent.currentTarget.ownerGlobal, + "menuitem-screenshot", + "toolbar_button" + ); + } else { + Services.obs.notifyObservers( + null, + "menuitem-screenshot-extension", + "toolbar" + ); + } + }, + onCreated(aNode) { + aNode.ownerGlobal.MozXULElement.insertFTLIfNeeded( + "browser/screenshots.ftl" + ); + Services.obs.addObserver(this, "toggle-screenshot-disable"); + }, + observe(subj, topic, data) { + let document = subj.document; + let button = document.getElementById("screenshot-button"); + + if (!button) { + return; + } + + if (data == "true") { + button.setAttribute("disabled", "true"); + } else { + button.removeAttribute("disabled"); + } + }, + }); +} + +let preferencesButton = { + id: "preferences-button", + l10nId: "toolbar-settings-button", + onCommand(aEvent) { + let win = aEvent.target.ownerGlobal; + win.openPreferences(undefined); + }, +}; +if (AppConstants.platform == "macosx") { + preferencesButton.shortcutId = "key_preferencesCmdMac"; +} +CustomizableWidgets.push(preferencesButton); + +if (Services.prefs.getBoolPref("privacy.panicButton.enabled")) { + CustomizableWidgets.push({ + id: "panic-button", + type: "view", + viewId: "PanelUI-panicView", + + forgetButtonCalled(aEvent) { + let doc = aEvent.target.ownerDocument; + let group = doc.getElementById("PanelUI-panic-timeSpan"); + let itemsToClear = [ + "cookies", + "history", + "openWindows", + "formdata", + "sessions", + "cache", + "downloads", + "offlineApps", + ]; + let newWindowPrivateState = PrivateBrowsingUtils.isWindowPrivate( + doc.defaultView + ) + ? "private" + : "non-private"; + let promise = lazy.Sanitizer.sanitize(itemsToClear, { + ignoreTimespan: false, + range: lazy.Sanitizer.getClearRange(+group.value), + privateStateForNewWindow: newWindowPrivateState, + }); + promise.then(function () { + let otherWindow = Services.wm.getMostRecentWindow("navigator:browser"); + if (otherWindow.closed) { + console.error("Got a closed window!"); + } + if (otherWindow.PanicButtonNotifier) { + otherWindow.PanicButtonNotifier.notify(); + } else { + otherWindow.PanicButtonNotifierShouldNotify = true; + } + }); + }, + handleEvent(aEvent) { + switch (aEvent.type) { + case "command": + this.forgetButtonCalled(aEvent); + break; + } + }, + onViewShowing(aEvent) { + let win = aEvent.target.ownerGlobal; + let doc = win.document; + let eventBlocker = null; + eventBlocker = doc.l10n.translateElements([aEvent.target]); + + let forgetButton = aEvent.target.querySelector( + "#PanelUI-panic-view-button" + ); + let group = doc.getElementById("PanelUI-panic-timeSpan"); + group.selectedItem = doc.getElementById("PanelUI-panic-5min"); + forgetButton.addEventListener("command", this); + + if (eventBlocker) { + aEvent.detail.addBlocker(eventBlocker); + } + }, + onViewHiding(aEvent) { + let forgetButton = aEvent.target.querySelector( + "#PanelUI-panic-view-button" + ); + forgetButton.removeEventListener("command", this); + }, + }); +} + +if (PrivateBrowsingUtils.enabled) { + CustomizableWidgets.push({ + id: "privatebrowsing-button", + l10nId: "toolbar-button-new-private-window", + shortcutId: "key_privatebrowsing", + onCommand(e) { + let win = e.target.ownerGlobal; + win.OpenBrowserWindow({ private: true }); + }, + }); +} diff --git a/browser/components/customizableui/CustomizeMode.sys.mjs b/browser/components/customizableui/CustomizeMode.sys.mjs new file mode 100644 index 0000000000..5f6d01d833 --- /dev/null +++ b/browser/components/customizableui/CustomizeMode.sys.mjs @@ -0,0 +1,2971 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const kPrefCustomizationDebug = "browser.uiCustomization.debug"; +const kPaletteId = "customization-palette"; +const kDragDataTypePrefix = "text/toolbarwrapper-id/"; +const kSkipSourceNodePref = "browser.uiCustomization.skipSourceNodeCheck"; +const kDrawInTitlebarPref = "browser.tabs.inTitlebar"; +const kCompactModeShowPref = "browser.compactmode.show"; +const kBookmarksToolbarPref = "browser.toolbars.bookmarks.visibility"; +const kKeepBroadcastAttributes = "keepbroadcastattributeswhencustomizing"; + +const kPanelItemContextMenu = "customizationPanelItemContextMenu"; +const kPaletteItemContextMenu = "customizationPaletteItemContextMenu"; + +const kDownloadAutohideCheckboxId = "downloads-button-autohide-checkbox"; +const kDownloadAutohidePanelId = "downloads-button-autohide-panel"; +const kDownloadAutoHidePref = "browser.download.autohideButton"; + +import { CustomizableUI } from "resource:///modules/CustomizableUI.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", + DragPositionManager: "resource:///modules/DragPositionManager.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs", +}); +ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () { + const kUrl = + "chrome://browser/locale/customizableui/customizableWidgets.properties"; + return Services.strings.createBundle(kUrl); +}); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gTouchBarUpdater", + "@mozilla.org/widget/touchbarupdater;1", + "nsITouchBarUpdater" +); + +let gDebug; +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + gDebug = Services.prefs.getBoolPref(kPrefCustomizationDebug, false); + let consoleOptions = { + maxLogLevel: gDebug ? "all" : "log", + prefix: "CustomizeMode", + }; + return new ConsoleAPI(consoleOptions); +}); + +var gDraggingInToolbars; + +var gTab; + +function closeGlobalTab() { + let win = gTab.ownerGlobal; + if (win.gBrowser.browsers.length == 1) { + win.BrowserOpenTab(); + } + win.gBrowser.removeTab(gTab, { animate: true }); + gTab = null; +} + +var gTabsProgressListener = { + onLocationChange(aBrowser, aWebProgress, aRequest, aLocation, aFlags) { + // Tear down customize mode when the customize mode tab loads some other page. + // Customize mode will be re-entered if "about:blank" is loaded again, so + // don't tear down in this case. + if ( + !gTab || + gTab.linkedBrowser != aBrowser || + aLocation.spec == "about:blank" + ) { + return; + } + + unregisterGlobalTab(); + }, +}; + +function unregisterGlobalTab() { + gTab.removeEventListener("TabClose", unregisterGlobalTab); + let win = gTab.ownerGlobal; + win.removeEventListener("unload", unregisterGlobalTab); + win.gBrowser.removeTabsProgressListener(gTabsProgressListener); + + gTab.removeAttribute("customizemode"); + + gTab = null; +} + +export function CustomizeMode(aWindow) { + this.window = aWindow; + this.document = aWindow.document; + this.browser = aWindow.gBrowser; + this.areas = new Set(); + + this._translationObserver = new aWindow.MutationObserver(mutations => + this._onTranslations(mutations) + ); + this._ensureCustomizationPanels(); + + let content = this.$("customization-content-container"); + if (!content) { + this.window.MozXULElement.insertFTLIfNeeded("browser/customizeMode.ftl"); + let container = this.$("customization-container"); + container.replaceChild( + this.window.MozXULElement.parseXULToFragment(container.firstChild.data), + container.lastChild + ); + } + // There are two palettes - there's the palette that can be overlayed with + // toolbar items in browser.xhtml. This is invisible, and never seen by the + // user. Then there's the visible palette, which gets populated and displayed + // to the user when in customizing mode. + this.visiblePalette = this.$(kPaletteId); + this.pongArena = this.$("customization-pong-arena"); + + if (this._canDrawInTitlebar()) { + this._updateTitlebarCheckbox(); + Services.prefs.addObserver(kDrawInTitlebarPref, this); + } else { + this.$("customization-titlebar-visibility-checkbox").hidden = true; + } + + // Observe pref changes to the bookmarks toolbar visibility, + // since we won't get a toolbarvisibilitychange event if the + // toolbar is changing from 'newtab' to 'always' in Customize mode + // since the toolbar is shown with the 'newtab' setting. + Services.prefs.addObserver(kBookmarksToolbarPref, this); + + this.window.addEventListener("unload", this); +} + +CustomizeMode.prototype = { + _changed: false, + _transitioning: false, + window: null, + document: null, + // areas is used to cache the customizable areas when in customization mode. + areas: null, + // When in customizing mode, we swap out the reference to the invisible + // palette in gNavToolbox.palette for our visiblePalette. This way, for the + // customizing browser window, when widgets are removed from customizable + // areas and added to the palette, they're added to the visible palette. + // _stowedPalette is a reference to the old invisible palette so we can + // restore gNavToolbox.palette to its original state after exiting + // customization mode. + _stowedPalette: null, + _dragOverItem: null, + _customizing: false, + _skipSourceNodeCheck: null, + _mainViewContext: null, + + // These are the commands we continue to leave enabled while in customize mode. + // All other commands are disabled, and we remove the disabled attribute when + // leaving customize mode. + _enabledCommands: new Set([ + "cmd_newNavigator", + "cmd_newNavigatorTab", + "cmd_newNavigatorTabNoEvent", + "cmd_close", + "cmd_closeWindow", + "cmd_quitApplication", + "View:FullScreen", + "Browser:NextTab", + "Browser:PrevTab", + "Browser:NewUserContextTab", + "Tools:PrivateBrowsing", + "minimizeWindow", + "zoomWindow", + ]), + + get _handler() { + return this.window.CustomizationHandler; + }, + + uninit() { + if (this._canDrawInTitlebar()) { + Services.prefs.removeObserver(kDrawInTitlebarPref, this); + } + Services.prefs.removeObserver(kBookmarksToolbarPref, this); + }, + + $(id) { + return this.document.getElementById(id); + }, + + toggle() { + if ( + this._handler.isEnteringCustomizeMode || + this._handler.isExitingCustomizeMode + ) { + this._wantToBeInCustomizeMode = !this._wantToBeInCustomizeMode; + return; + } + if (this._customizing) { + this.exit(); + } else { + this.enter(); + } + }, + + setTab(aTab) { + if (gTab == aTab) { + return; + } + + if (gTab) { + closeGlobalTab(); + } + + gTab = aTab; + + gTab.setAttribute("customizemode", "true"); + lazy.SessionStore.persistTabAttribute("customizemode"); + + if (gTab.linkedPanel) { + gTab.linkedBrowser.stop(); + } + + let win = gTab.ownerGlobal; + + win.gBrowser.setTabTitle(gTab); + win.gBrowser.setIcon(gTab, "chrome://browser/skin/customize.svg"); + + gTab.addEventListener("TabClose", unregisterGlobalTab); + + win.gBrowser.addTabsProgressListener(gTabsProgressListener); + + win.addEventListener("unload", unregisterGlobalTab); + + if (gTab.selected) { + win.gCustomizeMode.enter(); + } + }, + + enter() { + if (!this.window.toolbar.visible) { + let w = lazy.URILoadingHelper.getTargetWindow(this.window, { + skipPopups: true, + }); + if (w) { + w.gCustomizeMode.enter(); + return; + } + let obs = () => { + Services.obs.removeObserver(obs, "browser-delayed-startup-finished"); + w = lazy.URILoadingHelper.getTargetWindow(this.window, { + skipPopups: true, + }); + w.gCustomizeMode.enter(); + }; + Services.obs.addObserver(obs, "browser-delayed-startup-finished"); + this.window.openTrustedLinkIn("about:newtab", "window"); + return; + } + this._wantToBeInCustomizeMode = true; + + if (this._customizing || this._handler.isEnteringCustomizeMode) { + return; + } + + // Exiting; want to re-enter once we've done that. + if (this._handler.isExitingCustomizeMode) { + lazy.log.debug( + "Attempted to enter while we're in the middle of exiting. " + + "We'll exit after we've entered" + ); + return; + } + + if (!gTab) { + this.setTab( + this.browser.addTab("about:blank", { + inBackground: false, + forceNotRemote: true, + skipAnimation: true, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }) + ); + return; + } + if (!gTab.selected) { + // This will force another .enter() to be called via the + // onlocationchange handler of the tabbrowser, so we return early. + gTab.ownerGlobal.gBrowser.selectedTab = gTab; + return; + } + gTab.ownerGlobal.focus(); + if (gTab.ownerDocument != this.document) { + return; + } + + let window = this.window; + let document = this.document; + + this._handler.isEnteringCustomizeMode = true; + + // Always disable the reset button at the start of customize mode, it'll be re-enabled + // if necessary when we finish entering: + let resetButton = this.$("customization-reset-button"); + resetButton.setAttribute("disabled", "true"); + + (async () => { + // We shouldn't start customize mode until after browser-delayed-startup has finished: + if (!this.window.gBrowserInit.delayedStartupFinished) { + await new Promise(resolve => { + let delayedStartupObserver = aSubject => { + if (aSubject == this.window) { + Services.obs.removeObserver( + delayedStartupObserver, + "browser-delayed-startup-finished" + ); + resolve(); + } + }; + + Services.obs.addObserver( + delayedStartupObserver, + "browser-delayed-startup-finished" + ); + }); + } + + CustomizableUI.dispatchToolboxEvent("beforecustomization", {}, window); + CustomizableUI.notifyStartCustomizing(this.window); + + // Add a keypress listener to the document so that we can quickly exit + // customization mode when pressing ESC. + document.addEventListener("keypress", this); + + // Same goes for the menu button - if we're customizing, a click on the + // menu button means a quick exit from customization mode. + window.PanelUI.hide(); + + let panelHolder = document.getElementById("customization-panelHolder"); + let panelContextMenu = document.getElementById(kPanelItemContextMenu); + this._previousPanelContextMenuParent = panelContextMenu.parentNode; + document.getElementById("mainPopupSet").appendChild(panelContextMenu); + panelHolder.appendChild(window.PanelUI.overflowFixedList); + + window.PanelUI.overflowFixedList.setAttribute("customizing", true); + window.PanelUI.menuButton.disabled = true; + document.getElementById("nav-bar-overflow-button").disabled = true; + + this._transitioning = true; + + let customizer = document.getElementById("customization-container"); + let browser = document.getElementById("browser"); + browser.hidden = true; + customizer.hidden = false; + + this._wrapToolbarItemSync(CustomizableUI.AREA_TABSTRIP); + + this.document.documentElement.setAttribute("customizing", true); + + let customizableToolbars = document.querySelectorAll( + "toolbar[customizable=true]:not([autohide=true], [collapsed=true])" + ); + for (let toolbar of customizableToolbars) { + toolbar.setAttribute("customizing", true); + } + + this._updateOverflowPanelArrowOffset(); + + // Let everybody in this window know that we're about to customize. + CustomizableUI.dispatchToolboxEvent("customizationstarting", {}, window); + + await this._wrapToolbarItems(); + this.populatePalette(); + + this._setupPaletteDragging(); + + window.gNavToolbox.addEventListener("toolbarvisibilitychange", this); + + this._updateResetButton(); + this._updateUndoResetButton(); + this._updateTouchBarButton(); + this._updateDensityMenu(); + + this._skipSourceNodeCheck = + Services.prefs.getPrefType(kSkipSourceNodePref) == + Ci.nsIPrefBranch.PREF_BOOL && + Services.prefs.getBoolPref(kSkipSourceNodePref); + + CustomizableUI.addListener(this); + this._customizing = true; + this._transitioning = false; + + // Show the palette now that the transition has finished. + this.visiblePalette.hidden = false; + window.setTimeout(() => { + // Force layout reflow to ensure the animation runs, + // and make it async so it doesn't affect the timing. + this.visiblePalette.clientTop; + this.visiblePalette.setAttribute("showing", "true"); + }, 0); + this._updateEmptyPaletteNotice(); + + lazy.AddonManager.addAddonListener(this); + + this._setupDownloadAutoHideToggle(); + + this._handler.isEnteringCustomizeMode = false; + + CustomizableUI.dispatchToolboxEvent("customizationready", {}, window); + + if (!this._wantToBeInCustomizeMode) { + this.exit(); + } + })().catch(e => { + lazy.log.error("Error entering customize mode", e); + this._handler.isEnteringCustomizeMode = false; + // Exit customize mode to ensure proper clean-up when entering failed. + this.exit(); + }); + }, + + exit() { + this._wantToBeInCustomizeMode = false; + + if (!this._customizing || this._handler.isExitingCustomizeMode) { + return; + } + + // Entering; want to exit once we've done that. + if (this._handler.isEnteringCustomizeMode) { + lazy.log.debug( + "Attempted to exit while we're in the middle of entering. " + + "We'll exit after we've entered" + ); + return; + } + + if (this.resetting) { + lazy.log.debug( + "Attempted to exit while we're resetting. " + + "We'll exit after resetting has finished." + ); + return; + } + + this._handler.isExitingCustomizeMode = true; + + this._translationObserver.disconnect(); + + this._teardownDownloadAutoHideToggle(); + + lazy.AddonManager.removeAddonListener(this); + CustomizableUI.removeListener(this); + + let window = this.window; + let document = this.document; + + document.removeEventListener("keypress", this); + + this.togglePong(false); + + // Disable the reset and undo reset buttons while transitioning: + let resetButton = this.$("customization-reset-button"); + let undoResetButton = this.$("customization-undo-reset-button"); + undoResetButton.hidden = resetButton.disabled = true; + + this._transitioning = true; + + this._depopulatePalette(); + + // We need to set this._customizing to false and remove the `customizing` + // attribute before removing the tab or else + // XULBrowserWindow.onLocationChange might think that we're still in + // customization mode and need to exit it for a second time. + this._customizing = false; + document.documentElement.removeAttribute("customizing"); + + if (this.browser.selectedTab == gTab) { + closeGlobalTab(); + } + + let customizer = document.getElementById("customization-container"); + let browser = document.getElementById("browser"); + customizer.hidden = true; + browser.hidden = false; + + window.gNavToolbox.removeEventListener("toolbarvisibilitychange", this); + + this._teardownPaletteDragging(); + + (async () => { + await this._unwrapToolbarItems(); + + // And drop all area references. + this.areas.clear(); + + // Let everybody in this window know that we're starting to + // exit customization mode. + CustomizableUI.dispatchToolboxEvent("customizationending", {}, window); + + window.PanelUI.menuButton.disabled = false; + let overflowContainer = document.getElementById( + "widget-overflow-mainView" + ).firstElementChild; + overflowContainer.appendChild(window.PanelUI.overflowFixedList); + document.getElementById("nav-bar-overflow-button").disabled = false; + let panelContextMenu = document.getElementById(kPanelItemContextMenu); + this._previousPanelContextMenuParent.appendChild(panelContextMenu); + + let customizableToolbars = document.querySelectorAll( + "toolbar[customizable=true]:not([autohide=true])" + ); + for (let toolbar of customizableToolbars) { + toolbar.removeAttribute("customizing"); + } + + this._maybeMoveDownloadsButtonToNavBar(); + + delete this._lastLightweightTheme; + this._changed = false; + this._transitioning = false; + this._handler.isExitingCustomizeMode = false; + CustomizableUI.dispatchToolboxEvent("aftercustomization", {}, window); + CustomizableUI.notifyEndCustomizing(window); + + if (this._wantToBeInCustomizeMode) { + this.enter(); + } + })().catch(e => { + lazy.log.error("Error exiting customize mode", e); + this._handler.isExitingCustomizeMode = false; + }); + }, + + /** + * The overflow panel in customize mode should have its arrow pointing + * at the overflow button. In order to do this correctly, we pass the + * distance between the inside of window and the middle of the button + * to the customize mode markup in which the arrow and panel are placed. + */ + async _updateOverflowPanelArrowOffset() { + let currentDensity = + this.document.documentElement.getAttribute("uidensity"); + let offset = await this.window.promiseDocumentFlushed(() => { + let overflowButton = this.$("nav-bar-overflow-button"); + let buttonRect = overflowButton.getBoundingClientRect(); + let endDistance; + if (this.window.RTL_UI) { + endDistance = buttonRect.left; + } else { + endDistance = this.window.innerWidth - buttonRect.right; + } + return endDistance + buttonRect.width / 2; + }); + if ( + !this.document || + currentDensity != this.document.documentElement.getAttribute("uidensity") + ) { + return; + } + this.$("customization-panelWrapper").style.setProperty( + "--panel-arrow-offset", + offset + "px" + ); + }, + + _getCustomizableChildForNode(aNode) { + // NB: adjusted from _getCustomizableParent to keep that method fast + // (it's used during drags), and avoid multiple DOM loops + let areas = CustomizableUI.areas; + // Caching this length is important because otherwise we'll also iterate + // over items we add to the end from within the loop. + let numberOfAreas = areas.length; + for (let i = 0; i < numberOfAreas; i++) { + let area = areas[i]; + let areaNode = aNode.ownerDocument.getElementById(area); + let customizationTarget = CustomizableUI.getCustomizationTarget(areaNode); + if (customizationTarget && customizationTarget != areaNode) { + areas.push(customizationTarget.id); + } + let overflowTarget = + areaNode && areaNode.getAttribute("default-overflowtarget"); + if (overflowTarget) { + areas.push(overflowTarget); + } + } + areas.push(kPaletteId); + + while (aNode && aNode.parentNode) { + let parent = aNode.parentNode; + if (areas.includes(parent.id)) { + return aNode; + } + aNode = parent; + } + return null; + }, + + _promiseWidgetAnimationOut(aNode) { + if ( + this.window.gReduceMotion || + aNode.getAttribute("cui-anchorid") == "nav-bar-overflow-button" || + (aNode.tagName != "toolbaritem" && aNode.tagName != "toolbarbutton") || + (aNode.id == "downloads-button" && aNode.hidden) + ) { + return null; + } + + let animationNode; + if (aNode.parentNode && aNode.parentNode.id.startsWith("wrapper-")) { + animationNode = aNode.parentNode; + } else { + animationNode = aNode; + } + return new Promise(resolve => { + function cleanupCustomizationExit() { + resolveAnimationPromise(); + } + + function cleanupWidgetAnimationEnd(e) { + if ( + e.animationName == "widget-animate-out" && + e.target.id == animationNode.id + ) { + resolveAnimationPromise(); + } + } + + function resolveAnimationPromise() { + animationNode.removeEventListener( + "animationend", + cleanupWidgetAnimationEnd + ); + animationNode.removeEventListener( + "customizationending", + cleanupCustomizationExit + ); + resolve(animationNode); + } + + // Wait until the next frame before setting the class to ensure + // we do start the animation. + this.window.requestAnimationFrame(() => { + this.window.requestAnimationFrame(() => { + animationNode.classList.add("animate-out"); + animationNode.ownerGlobal.gNavToolbox.addEventListener( + "customizationending", + cleanupCustomizationExit + ); + animationNode.addEventListener( + "animationend", + cleanupWidgetAnimationEnd + ); + }); + }); + }); + }, + + async addToToolbar(aNode, aReason) { + aNode = this._getCustomizableChildForNode(aNode); + if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) { + aNode = aNode.firstElementChild; + } + let widgetAnimationPromise = this._promiseWidgetAnimationOut(aNode); + let animationNode; + if (widgetAnimationPromise) { + animationNode = await widgetAnimationPromise; + } + + let widgetToAdd = aNode.id; + if ( + CustomizableUI.isSpecialWidget(widgetToAdd) && + aNode.closest("#customization-palette") + ) { + widgetToAdd = widgetToAdd.match( + /^customizableui-special-(spring|spacer|separator)/ + )[1]; + } + + CustomizableUI.addWidgetToArea(widgetToAdd, CustomizableUI.AREA_NAVBAR); + lazy.BrowserUsageTelemetry.recordWidgetChange( + widgetToAdd, + CustomizableUI.AREA_NAVBAR + ); + if (!this._customizing) { + CustomizableUI.dispatchToolboxEvent("customizationchange"); + } + + // If the user explicitly moves this item, turn off autohide. + if (aNode.id == "downloads-button") { + Services.prefs.setBoolPref(kDownloadAutoHidePref, false); + if (this._customizing) { + this._showDownloadsAutoHidePanel(); + } + } + + if (animationNode) { + animationNode.classList.remove("animate-out"); + } + }, + + async addToPanel(aNode, aReason) { + aNode = this._getCustomizableChildForNode(aNode); + if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) { + aNode = aNode.firstElementChild; + } + let widgetAnimationPromise = this._promiseWidgetAnimationOut(aNode); + let animationNode; + if (widgetAnimationPromise) { + animationNode = await widgetAnimationPromise; + } + + let panel = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL; + CustomizableUI.addWidgetToArea(aNode.id, panel); + lazy.BrowserUsageTelemetry.recordWidgetChange(aNode.id, panel, aReason); + if (!this._customizing) { + CustomizableUI.dispatchToolboxEvent("customizationchange"); + } + + // If the user explicitly moves this item, turn off autohide. + if (aNode.id == "downloads-button") { + Services.prefs.setBoolPref(kDownloadAutoHidePref, false); + if (this._customizing) { + this._showDownloadsAutoHidePanel(); + } + } + + if (animationNode) { + animationNode.classList.remove("animate-out"); + } + if (!this.window.gReduceMotion) { + let overflowButton = this.$("nav-bar-overflow-button"); + overflowButton.setAttribute("animate", "true"); + overflowButton.addEventListener( + "animationend", + function onAnimationEnd(event) { + if (event.animationName.startsWith("overflow-animation")) { + this.removeEventListener("animationend", onAnimationEnd); + this.removeAttribute("animate"); + } + } + ); + } + }, + + async removeFromArea(aNode, aReason) { + aNode = this._getCustomizableChildForNode(aNode); + if (aNode.localName == "toolbarpaletteitem" && aNode.firstElementChild) { + aNode = aNode.firstElementChild; + } + let widgetAnimationPromise = this._promiseWidgetAnimationOut(aNode); + let animationNode; + if (widgetAnimationPromise) { + animationNode = await widgetAnimationPromise; + } + + CustomizableUI.removeWidgetFromArea(aNode.id); + lazy.BrowserUsageTelemetry.recordWidgetChange(aNode.id, null, aReason); + if (!this._customizing) { + CustomizableUI.dispatchToolboxEvent("customizationchange"); + } + + // If the user explicitly removes this item, turn off autohide. + if (aNode.id == "downloads-button") { + Services.prefs.setBoolPref(kDownloadAutoHidePref, false); + if (this._customizing) { + this._showDownloadsAutoHidePanel(); + } + } + if (animationNode) { + animationNode.classList.remove("animate-out"); + } + }, + + populatePalette() { + let fragment = this.document.createDocumentFragment(); + let toolboxPalette = this.window.gNavToolbox.palette; + + try { + let unusedWidgets = CustomizableUI.getUnusedWidgets(toolboxPalette); + for (let widget of unusedWidgets) { + let paletteItem = this.makePaletteItem(widget, "palette"); + if (!paletteItem) { + continue; + } + fragment.appendChild(paletteItem); + } + + let flexSpace = CustomizableUI.createSpecialWidget( + "spring", + this.document + ); + fragment.appendChild(this.wrapToolbarItem(flexSpace, "palette")); + + this.visiblePalette.appendChild(fragment); + this._stowedPalette = this.window.gNavToolbox.palette; + this.window.gNavToolbox.palette = this.visiblePalette; + + // Now that the palette items are all here, disable all commands. + // We do this here rather than directly in `enter` because we + // need to do/undo this when we're called from reset(), too. + this._updateCommandsDisabledState(true); + } catch (ex) { + lazy.log.error(ex); + } + }, + + // XXXunf Maybe this should use -moz-element instead of wrapping the node? + // Would ensure no weird interactions/event handling from original node, + // and makes it possible to put this in a lazy-loaded iframe/real tab + // while still getting rid of the need for overlays. + makePaletteItem(aWidget, aPlace) { + let widgetNode = aWidget.forWindow(this.window).node; + if (!widgetNode) { + lazy.log.error( + "Widget with id " + aWidget.id + " does not return a valid node" + ); + return null; + } + // Do not build a palette item for hidden widgets; there's not much to show. + if (widgetNode.hidden) { + return null; + } + + let wrapper = this.createOrUpdateWrapper(widgetNode, aPlace); + wrapper.appendChild(widgetNode); + return wrapper; + }, + + _depopulatePalette() { + // Quick, undo the command disabling before we depopulate completely: + this._updateCommandsDisabledState(false); + + this.visiblePalette.hidden = true; + let paletteChild = this.visiblePalette.firstElementChild; + let nextChild; + while (paletteChild) { + nextChild = paletteChild.nextElementSibling; + let itemId = paletteChild.firstElementChild.id; + if (CustomizableUI.isSpecialWidget(itemId)) { + this.visiblePalette.removeChild(paletteChild); + } else { + // XXXunf Currently this doesn't destroy the (now unused) node in the + // API provider case. It would be good to do so, but we need to + // keep strong refs to it in CustomizableUI (can't iterate of + // WeakMaps), and there's the question of what behavior + // wrappers should have if consumers keep hold of them. + let unwrappedPaletteItem = this.unwrapToolbarItem(paletteChild); + this._stowedPalette.appendChild(unwrappedPaletteItem); + } + + paletteChild = nextChild; + } + this.visiblePalette.hidden = false; + this.window.gNavToolbox.palette = this._stowedPalette; + }, + + _updateCommandsDisabledState(shouldBeDisabled) { + for (let command of this.document.querySelectorAll("command")) { + if (!command.id || !this._enabledCommands.has(command.id)) { + if (shouldBeDisabled) { + if (command.getAttribute("disabled") != "true") { + command.setAttribute("disabled", true); + } else { + command.setAttribute("wasdisabled", true); + } + } else if (command.getAttribute("wasdisabled") != "true") { + command.removeAttribute("disabled"); + } else { + command.removeAttribute("wasdisabled"); + } + } + } + }, + + isCustomizableItem(aNode) { + return ( + aNode.localName == "toolbarbutton" || + aNode.localName == "toolbaritem" || + aNode.localName == "toolbarseparator" || + aNode.localName == "toolbarspring" || + aNode.localName == "toolbarspacer" + ); + }, + + isWrappedToolbarItem(aNode) { + return aNode.localName == "toolbarpaletteitem"; + }, + + deferredWrapToolbarItem(aNode, aPlace) { + return new Promise(resolve => { + dispatchFunction(() => { + let wrapper = this.wrapToolbarItem(aNode, aPlace); + resolve(wrapper); + }); + }); + }, + + wrapToolbarItem(aNode, aPlace) { + if (!this.isCustomizableItem(aNode)) { + return aNode; + } + let wrapper = this.createOrUpdateWrapper(aNode, aPlace); + + // It's possible that this toolbar node is "mid-flight" and doesn't have + // a parent, in which case we skip replacing it. This can happen if a + // toolbar item has been dragged into the palette. In that case, we tell + // CustomizableUI to remove the widget from its area before putting the + // widget in the palette - so the node will have no parent. + if (aNode.parentNode) { + aNode = aNode.parentNode.replaceChild(wrapper, aNode); + } + wrapper.appendChild(aNode); + return wrapper; + }, + + /** + * Helper to set the label, either directly or to set up the translation + * observer so we can set the label once it's available. + */ + _updateWrapperLabel(aNode, aIsUpdate, aWrapper = aNode.parentElement) { + if (aNode.hasAttribute("label")) { + aWrapper.setAttribute("title", aNode.getAttribute("label")); + aWrapper.setAttribute("tooltiptext", aNode.getAttribute("label")); + } else if (aNode.hasAttribute("title")) { + aWrapper.setAttribute("title", aNode.getAttribute("title")); + aWrapper.setAttribute("tooltiptext", aNode.getAttribute("title")); + } else if (aNode.hasAttribute("data-l10n-id") && !aIsUpdate) { + this._translationObserver.observe(aNode, { + attributes: true, + attributeFilter: ["label", "title"], + }); + } + }, + + /** + * Called when a node without a label or title is updated. + */ + _onTranslations(aMutations) { + for (let mut of aMutations) { + let { target } = mut; + if ( + target.parentElement?.localName == "toolbarpaletteitem" && + (target.hasAttribute("label") || mut.target.hasAttribute("title")) + ) { + this._updateWrapperLabel(target, true); + } + } + }, + + createOrUpdateWrapper(aNode, aPlace, aIsUpdate) { + let wrapper; + if ( + aIsUpdate && + aNode.parentNode && + aNode.parentNode.localName == "toolbarpaletteitem" + ) { + wrapper = aNode.parentNode; + aPlace = wrapper.getAttribute("place"); + } else { + wrapper = this.document.createXULElement("toolbarpaletteitem"); + // "place" is used to show the label when it's sitting in the palette. + wrapper.setAttribute("place", aPlace); + } + + // Ensure the wrapped item doesn't look like it's in any special state, and + // can't be interactved with when in the customization palette. + // Note that some buttons opt out of this with the + // keepbroadcastattributeswhencustomizing attribute. + if ( + aNode.hasAttribute("command") && + aNode.getAttribute(kKeepBroadcastAttributes) != "true" + ) { + wrapper.setAttribute("itemcommand", aNode.getAttribute("command")); + aNode.removeAttribute("command"); + } + + if ( + aNode.hasAttribute("observes") && + aNode.getAttribute(kKeepBroadcastAttributes) != "true" + ) { + wrapper.setAttribute("itemobserves", aNode.getAttribute("observes")); + aNode.removeAttribute("observes"); + } + + if (aNode.getAttribute("checked") == "true") { + wrapper.setAttribute("itemchecked", "true"); + aNode.removeAttribute("checked"); + } + + if (aNode.hasAttribute("id")) { + wrapper.setAttribute("id", "wrapper-" + aNode.getAttribute("id")); + } + + this._updateWrapperLabel(aNode, aIsUpdate, wrapper); + + if (aNode.hasAttribute("flex")) { + wrapper.setAttribute("flex", aNode.getAttribute("flex")); + } + + let removable = + aPlace == "palette" || CustomizableUI.isWidgetRemovable(aNode); + wrapper.setAttribute("removable", removable); + + // Allow touch events to initiate dragging in customize mode. + // This is only supported on Windows for now. + wrapper.setAttribute("touchdownstartsdrag", "true"); + + let contextMenuAttrName = ""; + if (aNode.getAttribute("context")) { + contextMenuAttrName = "context"; + } else if (aNode.getAttribute("contextmenu")) { + contextMenuAttrName = "contextmenu"; + } + let currentContextMenu = aNode.getAttribute(contextMenuAttrName); + let contextMenuForPlace = + aPlace == "panel" ? kPanelItemContextMenu : kPaletteItemContextMenu; + if (aPlace != "toolbar") { + wrapper.setAttribute("context", contextMenuForPlace); + } + // Only keep track of the menu if it is non-default. + if (currentContextMenu && currentContextMenu != contextMenuForPlace) { + aNode.setAttribute("wrapped-context", currentContextMenu); + aNode.setAttribute("wrapped-contextAttrName", contextMenuAttrName); + aNode.removeAttribute(contextMenuAttrName); + } else if (currentContextMenu == contextMenuForPlace) { + aNode.removeAttribute(contextMenuAttrName); + } + + // Only add listeners for newly created wrappers: + if (!aIsUpdate) { + wrapper.addEventListener("mousedown", this); + wrapper.addEventListener("mouseup", this); + } + + if (CustomizableUI.isSpecialWidget(aNode.id)) { + wrapper.setAttribute( + "title", + lazy.gWidgetsBundle.GetStringFromName(aNode.nodeName + ".label") + ); + } + + return wrapper; + }, + + deferredUnwrapToolbarItem(aWrapper) { + return new Promise(resolve => { + dispatchFunction(() => { + let item = null; + try { + item = this.unwrapToolbarItem(aWrapper); + } catch (ex) { + console.error(ex); + } + resolve(item); + }); + }); + }, + + unwrapToolbarItem(aWrapper) { + if (aWrapper.nodeName != "toolbarpaletteitem") { + return aWrapper; + } + aWrapper.removeEventListener("mousedown", this); + aWrapper.removeEventListener("mouseup", this); + + let place = aWrapper.getAttribute("place"); + + let toolbarItem = aWrapper.firstElementChild; + if (!toolbarItem) { + lazy.log.error( + "no toolbarItem child for " + aWrapper.tagName + "#" + aWrapper.id + ); + aWrapper.remove(); + return null; + } + + if (aWrapper.hasAttribute("itemobserves")) { + toolbarItem.setAttribute( + "observes", + aWrapper.getAttribute("itemobserves") + ); + } + + if (aWrapper.hasAttribute("itemchecked")) { + toolbarItem.checked = true; + } + + if (aWrapper.hasAttribute("itemcommand")) { + let commandID = aWrapper.getAttribute("itemcommand"); + toolbarItem.setAttribute("command", commandID); + + // XXX Bug 309953 - toolbarbuttons aren't in sync with their commands after customizing + let command = this.$(commandID); + if (command && command.hasAttribute("disabled")) { + toolbarItem.setAttribute("disabled", command.getAttribute("disabled")); + } + } + + let wrappedContext = toolbarItem.getAttribute("wrapped-context"); + if (wrappedContext) { + let contextAttrName = toolbarItem.getAttribute("wrapped-contextAttrName"); + toolbarItem.setAttribute(contextAttrName, wrappedContext); + toolbarItem.removeAttribute("wrapped-contextAttrName"); + toolbarItem.removeAttribute("wrapped-context"); + } else if (place == "panel") { + toolbarItem.setAttribute("context", kPanelItemContextMenu); + } + + if (aWrapper.parentNode) { + aWrapper.parentNode.replaceChild(toolbarItem, aWrapper); + } + return toolbarItem; + }, + + async _wrapToolbarItem(aArea) { + let target = CustomizableUI.getCustomizeTargetForArea(aArea, this.window); + if (!target || this.areas.has(target)) { + return null; + } + + this._addDragHandlers(target); + for (let child of target.children) { + if (this.isCustomizableItem(child) && !this.isWrappedToolbarItem(child)) { + await this.deferredWrapToolbarItem( + child, + CustomizableUI.getPlaceForItem(child) + ).catch(lazy.log.error); + } + } + this.areas.add(target); + return target; + }, + + _wrapToolbarItemSync(aArea) { + let target = CustomizableUI.getCustomizeTargetForArea(aArea, this.window); + if (!target || this.areas.has(target)) { + return null; + } + + this._addDragHandlers(target); + try { + for (let child of target.children) { + if ( + this.isCustomizableItem(child) && + !this.isWrappedToolbarItem(child) + ) { + this.wrapToolbarItem(child, CustomizableUI.getPlaceForItem(child)); + } + } + } catch (ex) { + lazy.log.error(ex, ex.stack); + } + + this.areas.add(target); + return target; + }, + + async _wrapToolbarItems() { + for (let area of CustomizableUI.areas) { + await this._wrapToolbarItem(area); + } + }, + + _addDragHandlers(aTarget) { + // Allow dropping on the padding of the arrow panel. + if (aTarget.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) { + aTarget = this.$("customization-panelHolder"); + } + aTarget.addEventListener("dragstart", this, true); + aTarget.addEventListener("dragover", this, true); + aTarget.addEventListener("dragleave", this, true); + aTarget.addEventListener("drop", this, true); + aTarget.addEventListener("dragend", this, true); + }, + + _wrapItemsInArea(target) { + for (let child of target.children) { + if (this.isCustomizableItem(child)) { + this.wrapToolbarItem(child, CustomizableUI.getPlaceForItem(child)); + } + } + }, + + _removeDragHandlers(aTarget) { + // Remove handler from different target if it was added to + // allow dropping on the padding of the arrow panel. + if (aTarget.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) { + aTarget = this.$("customization-panelHolder"); + } + aTarget.removeEventListener("dragstart", this, true); + aTarget.removeEventListener("dragover", this, true); + aTarget.removeEventListener("dragleave", this, true); + aTarget.removeEventListener("drop", this, true); + aTarget.removeEventListener("dragend", this, true); + }, + + _unwrapItemsInArea(target) { + for (let toolbarItem of target.children) { + if (this.isWrappedToolbarItem(toolbarItem)) { + this.unwrapToolbarItem(toolbarItem); + } + } + }, + + _unwrapToolbarItems() { + return (async () => { + for (let target of this.areas) { + for (let toolbarItem of target.children) { + if (this.isWrappedToolbarItem(toolbarItem)) { + await this.deferredUnwrapToolbarItem(toolbarItem); + } + } + this._removeDragHandlers(target); + } + this.areas.clear(); + })().catch(lazy.log.error); + }, + + reset() { + this.resetting = true; + // Disable the reset button temporarily while resetting: + let btn = this.$("customization-reset-button"); + btn.disabled = true; + return (async () => { + this._depopulatePalette(); + await this._unwrapToolbarItems(); + + CustomizableUI.reset(); + + await this._wrapToolbarItems(); + this.populatePalette(); + + this._updateResetButton(); + this._updateUndoResetButton(); + this._updateEmptyPaletteNotice(); + this._moveDownloadsButtonToNavBar = false; + this.resetting = false; + if (!this._wantToBeInCustomizeMode) { + this.exit(); + } + })().catch(lazy.log.error); + }, + + undoReset() { + this.resetting = true; + + return (async () => { + this._depopulatePalette(); + await this._unwrapToolbarItems(); + + CustomizableUI.undoReset(); + + await this._wrapToolbarItems(); + this.populatePalette(); + + this._updateResetButton(); + this._updateUndoResetButton(); + this._updateEmptyPaletteNotice(); + this._moveDownloadsButtonToNavBar = false; + this.resetting = false; + })().catch(lazy.log.error); + }, + + _onToolbarVisibilityChange(aEvent) { + let toolbar = aEvent.target; + if ( + aEvent.detail.visible && + toolbar.getAttribute("customizable") == "true" + ) { + toolbar.setAttribute("customizing", "true"); + } else { + toolbar.removeAttribute("customizing"); + } + this._onUIChange(); + }, + + onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) { + this._onUIChange(); + }, + + onWidgetAdded(aWidgetId, aArea, aPosition) { + this._onUIChange(); + }, + + onWidgetRemoved(aWidgetId, aArea) { + this._onUIChange(); + }, + + onWidgetBeforeDOMChange(aNodeToChange, aSecondaryNode, aContainer) { + if (aContainer.ownerGlobal != this.window || this.resetting) { + return; + } + // If we get called for widgets that aren't in the window yet, they might not have + // a parentNode at all. + if (aNodeToChange.parentNode) { + this.unwrapToolbarItem(aNodeToChange.parentNode); + } + if (aSecondaryNode) { + this.unwrapToolbarItem(aSecondaryNode.parentNode); + } + }, + + onWidgetAfterDOMChange(aNodeToChange, aSecondaryNode, aContainer) { + if (aContainer.ownerGlobal != this.window || this.resetting) { + return; + } + // If the node is still attached to the container, wrap it again: + if (aNodeToChange.parentNode) { + let place = CustomizableUI.getPlaceForItem(aNodeToChange); + this.wrapToolbarItem(aNodeToChange, place); + if (aSecondaryNode) { + this.wrapToolbarItem(aSecondaryNode, place); + } + } else { + // If not, it got removed. + + // If an API-based widget is removed while customizing, append it to the palette. + // The _applyDrop code itself will take care of positioning it correctly, if + // applicable. We need the code to be here so removing widgets using CustomizableUI's + // API also does the right thing (and adds it to the palette) + let widgetId = aNodeToChange.id; + let widget = CustomizableUI.getWidget(widgetId); + if (widget.provider == CustomizableUI.PROVIDER_API) { + let paletteItem = this.makePaletteItem(widget, "palette"); + this.visiblePalette.appendChild(paletteItem); + } + } + }, + + onWidgetDestroyed(aWidgetId) { + let wrapper = this.$("wrapper-" + aWidgetId); + if (wrapper) { + wrapper.remove(); + } + }, + + onWidgetAfterCreation(aWidgetId, aArea) { + // If the node was added to an area, we would have gotten an onWidgetAdded notification, + // plus associated DOM change notifications, so only do stuff for the palette: + if (!aArea) { + let widgetNode = this.$(aWidgetId); + if (widgetNode) { + this.wrapToolbarItem(widgetNode, "palette"); + } else { + let widget = CustomizableUI.getWidget(aWidgetId); + this.visiblePalette.appendChild( + this.makePaletteItem(widget, "palette") + ); + } + } + }, + + onAreaNodeRegistered(aArea, aContainer) { + if (aContainer.ownerDocument == this.document) { + this._wrapItemsInArea(aContainer); + this._addDragHandlers(aContainer); + this.areas.add(aContainer); + } + }, + + onAreaNodeUnregistered(aArea, aContainer, aReason) { + if ( + aContainer.ownerDocument == this.document && + aReason == CustomizableUI.REASON_AREA_UNREGISTERED + ) { + this._unwrapItemsInArea(aContainer); + this._removeDragHandlers(aContainer); + this.areas.delete(aContainer); + } + }, + + openAddonsManagerThemes() { + this.window.BrowserOpenAddonsMgr("addons://list/theme"); + }, + + getMoreThemes(aEvent) { + aEvent.target.parentNode.parentNode.hidePopup(); + let getMoreURL = Services.urlFormatter.formatURLPref( + "lightweightThemes.getMoreURL" + ); + this.window.openTrustedLinkIn(getMoreURL, "tab"); + }, + + updateUIDensity(mode) { + this.window.gUIDensity.update(mode); + this._updateOverflowPanelArrowOffset(); + }, + + setUIDensity(mode) { + let win = this.window; + let gUIDensity = win.gUIDensity; + let currentDensity = gUIDensity.getCurrentDensity(); + let panel = win.document.getElementById("customization-uidensity-menu"); + + Services.prefs.setIntPref(gUIDensity.uiDensityPref, mode); + + // If the user is choosing a different UI density mode while + // the mode is overriden to Touch, remove the override. + if (currentDensity.overridden) { + Services.prefs.setBoolPref(gUIDensity.autoTouchModePref, false); + } + + this._onUIChange(); + panel.hidePopup(); + this._updateOverflowPanelArrowOffset(); + }, + + resetUIDensity() { + this.window.gUIDensity.update(); + this._updateOverflowPanelArrowOffset(); + }, + + onUIDensityMenuShowing() { + let win = this.window; + let doc = win.document; + let gUIDensity = win.gUIDensity; + let currentDensity = gUIDensity.getCurrentDensity(); + + let normalItem = doc.getElementById( + "customization-uidensity-menuitem-normal" + ); + normalItem.mode = gUIDensity.MODE_NORMAL; + + let items = [normalItem]; + + let compactItem = doc.getElementById( + "customization-uidensity-menuitem-compact" + ); + compactItem.mode = gUIDensity.MODE_COMPACT; + + if (Services.prefs.getBoolPref(kCompactModeShowPref)) { + compactItem.hidden = false; + items.push(compactItem); + } else { + compactItem.hidden = true; + } + + let touchItem = doc.getElementById( + "customization-uidensity-menuitem-touch" + ); + // Touch mode can not be enabled in OSX right now. + if (touchItem) { + touchItem.mode = gUIDensity.MODE_TOUCH; + items.push(touchItem); + } + + // Mark the active mode menuitem. + for (let item of items) { + if (item.mode == currentDensity.mode) { + item.setAttribute("aria-checked", "true"); + item.setAttribute("active", "true"); + } else { + item.removeAttribute("aria-checked"); + item.removeAttribute("active"); + } + } + + // Add menu items for automatically switching to Touch mode in Windows Tablet Mode. + if (AppConstants.platform == "win") { + let spacer = doc.getElementById("customization-uidensity-touch-spacer"); + let checkbox = doc.getElementById( + "customization-uidensity-autotouchmode-checkbox" + ); + spacer.removeAttribute("hidden"); + checkbox.removeAttribute("hidden"); + + // Show a hint that the UI density was overridden automatically. + if (currentDensity.overridden) { + let sb = Services.strings.createBundle( + "chrome://browser/locale/uiDensity.properties" + ); + touchItem.setAttribute( + "acceltext", + sb.GetStringFromName("uiDensity.menuitem-touch.acceltext") + ); + } else { + touchItem.removeAttribute("acceltext"); + } + + let autoTouchMode = Services.prefs.getBoolPref( + win.gUIDensity.autoTouchModePref + ); + if (autoTouchMode) { + checkbox.setAttribute("checked", "true"); + } else { + checkbox.removeAttribute("checked"); + } + } + }, + + updateAutoTouchMode(checked) { + Services.prefs.setBoolPref("browser.touchmode.auto", checked); + // Re-render the menu items since the active mode might have + // change because of this. + this.onUIDensityMenuShowing(); + this._onUIChange(); + }, + + _onUIChange() { + this._changed = true; + if (!this.resetting) { + this._updateResetButton(); + this._updateUndoResetButton(); + this._updateEmptyPaletteNotice(); + } + CustomizableUI.dispatchToolboxEvent("customizationchange"); + }, + + _updateEmptyPaletteNotice() { + let paletteItems = + this.visiblePalette.getElementsByTagName("toolbarpaletteitem"); + let whimsyButton = this.$("whimsy-button"); + + if ( + paletteItems.length == 1 && + paletteItems[0].id.includes("wrapper-customizableui-special-spring") + ) { + whimsyButton.hidden = false; + } else { + this.togglePong(false); + whimsyButton.hidden = true; + } + }, + + _updateResetButton() { + let btn = this.$("customization-reset-button"); + btn.disabled = CustomizableUI.inDefaultState; + }, + + _updateUndoResetButton() { + let undoResetButton = this.$("customization-undo-reset-button"); + undoResetButton.hidden = !CustomizableUI.canUndoReset; + }, + + _updateTouchBarButton() { + if (AppConstants.platform != "macosx") { + return; + } + let touchBarButton = this.$("customization-touchbar-button"); + let touchBarSpacer = this.$("customization-touchbar-spacer"); + + let isTouchBarInitialized = lazy.gTouchBarUpdater.isTouchBarInitialized(); + touchBarButton.hidden = !isTouchBarInitialized; + touchBarSpacer.hidden = !isTouchBarInitialized; + }, + + _updateDensityMenu() { + // If we're entering Customize Mode, and we're using compact mode, + // then show the button after that. + let gUIDensity = this.window.gUIDensity; + if (gUIDensity.getCurrentDensity().mode == gUIDensity.MODE_COMPACT) { + Services.prefs.setBoolPref(kCompactModeShowPref, true); + } + + let button = this.document.getElementById("customization-uidensity-button"); + button.hidden = + !Services.prefs.getBoolPref(kCompactModeShowPref) && + !button.querySelector("#customization-uidensity-menuitem-touch"); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "toolbarvisibilitychange": + this._onToolbarVisibilityChange(aEvent); + break; + case "dragstart": + this._onDragStart(aEvent); + break; + case "dragover": + this._onDragOver(aEvent); + break; + case "drop": + this._onDragDrop(aEvent); + break; + case "dragleave": + this._onDragLeave(aEvent); + break; + case "dragend": + this._onDragEnd(aEvent); + break; + case "mousedown": + this._onMouseDown(aEvent); + break; + case "mouseup": + this._onMouseUp(aEvent); + break; + case "keypress": + if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) { + this.exit(); + } + break; + case "unload": + this.uninit(); + break; + } + }, + + /** + * We handle dragover/drop on the outer palette separately + * to avoid overlap with other drag/drop handlers. + */ + _setupPaletteDragging() { + this._addDragHandlers(this.visiblePalette); + + this.paletteDragHandler = aEvent => { + let originalTarget = aEvent.originalTarget; + if ( + this._isUnwantedDragDrop(aEvent) || + this.visiblePalette.contains(originalTarget) || + this.$("customization-panelHolder").contains(originalTarget) + ) { + return; + } + // We have a dragover/drop on the palette. + if (aEvent.type == "dragover") { + this._onDragOver(aEvent, this.visiblePalette); + } else { + this._onDragDrop(aEvent, this.visiblePalette); + } + }; + let contentContainer = this.$("customization-content-container"); + contentContainer.addEventListener( + "dragover", + this.paletteDragHandler, + true + ); + contentContainer.addEventListener("drop", this.paletteDragHandler, true); + }, + + _teardownPaletteDragging() { + lazy.DragPositionManager.stop(); + this._removeDragHandlers(this.visiblePalette); + + let contentContainer = this.$("customization-content-container"); + contentContainer.removeEventListener( + "dragover", + this.paletteDragHandler, + true + ); + contentContainer.removeEventListener("drop", this.paletteDragHandler, true); + delete this.paletteDragHandler; + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "nsPref:changed": + this._updateResetButton(); + this._updateUndoResetButton(); + if (this._canDrawInTitlebar()) { + this._updateTitlebarCheckbox(); + } + break; + } + }, + + async onInstalled(addon) { + await this.onEnabled(addon); + }, + + async onEnabled(addon) { + if (addon.type != "theme") { + return; + } + + if (this._nextThemeChangeUserTriggered) { + this._onUIChange(); + } + this._nextThemeChangeUserTriggered = false; + }, + + _canDrawInTitlebar() { + return this.window.TabsInTitlebar.systemSupported; + }, + + _ensureCustomizationPanels() { + let template = this.$("customizationPanel"); + template.replaceWith(template.content); + + let wrapper = this.$("customModeWrapper"); + wrapper.replaceWith(wrapper.content); + }, + + _updateTitlebarCheckbox() { + let drawInTitlebar = Services.appinfo.drawInTitlebar; + let checkbox = this.$("customization-titlebar-visibility-checkbox"); + // Drawing in the titlebar means 'hiding' the titlebar. + // We use the attribute rather than a property because if we're not in + // customize mode the button is hidden and properties don't work. + if (drawInTitlebar) { + checkbox.removeAttribute("checked"); + } else { + checkbox.setAttribute("checked", "true"); + } + }, + + toggleTitlebar(aShouldShowTitlebar) { + // Drawing in the titlebar means not showing the titlebar, hence the negation: + Services.prefs.setIntPref(kDrawInTitlebarPref, !aShouldShowTitlebar); + }, + + _getBoundsWithoutFlushing(element) { + return this.window.windowUtils.getBoundsWithoutFlushing(element); + }, + + _onDragStart(aEvent) { + __dumpDragData(aEvent); + let item = aEvent.target; + while (item && item.localName != "toolbarpaletteitem") { + if ( + item.localName == "toolbar" || + item.id == kPaletteId || + item.id == "customization-panelHolder" + ) { + return; + } + item = item.parentNode; + } + + let draggedItem = item.firstElementChild; + let placeForItem = CustomizableUI.getPlaceForItem(item); + + let dt = aEvent.dataTransfer; + let documentId = aEvent.target.ownerDocument.documentElement.id; + + dt.mozSetDataAt(kDragDataTypePrefix + documentId, draggedItem.id, 0); + dt.effectAllowed = "move"; + + let itemRect = this._getBoundsWithoutFlushing(draggedItem); + let itemCenter = { + x: itemRect.left + itemRect.width / 2, + y: itemRect.top + itemRect.height / 2, + }; + this._dragOffset = { + x: aEvent.clientX - itemCenter.x, + y: aEvent.clientY - itemCenter.y, + }; + + let toolbarParent = draggedItem.closest("toolbar"); + if (toolbarParent) { + let toolbarRect = this._getBoundsWithoutFlushing(toolbarParent); + toolbarParent.style.minHeight = toolbarRect.height + "px"; + } + + gDraggingInToolbars = new Set(); + + // Hack needed so that the dragimage will still show the + // item as it appeared before it was hidden. + this._initializeDragAfterMove = () => { + // For automated tests, we sometimes start exiting customization mode + // before this fires, which leaves us with placeholders inserted after + // we've exited. So we need to check that we are indeed customizing. + if (this._customizing && !this._transitioning) { + item.hidden = true; + lazy.DragPositionManager.start(this.window); + let canUsePrevSibling = + placeForItem == "toolbar" || placeForItem == "panel"; + if (item.nextElementSibling) { + this._setDragActive( + item.nextElementSibling, + "before", + draggedItem.id, + placeForItem + ); + this._dragOverItem = item.nextElementSibling; + } else if (canUsePrevSibling && item.previousElementSibling) { + this._setDragActive( + item.previousElementSibling, + "after", + draggedItem.id, + placeForItem + ); + this._dragOverItem = item.previousElementSibling; + } + let currentArea = this._getCustomizableParent(item); + currentArea.setAttribute("draggingover", "true"); + } + this._initializeDragAfterMove = null; + this.window.clearTimeout(this._dragInitializeTimeout); + }; + this._dragInitializeTimeout = this.window.setTimeout( + this._initializeDragAfterMove, + 0 + ); + }, + + _onDragOver(aEvent, aOverrideTarget) { + if (this._isUnwantedDragDrop(aEvent)) { + return; + } + if (this._initializeDragAfterMove) { + this._initializeDragAfterMove(); + } + + __dumpDragData(aEvent); + + let document = aEvent.target.ownerDocument; + let documentId = document.documentElement.id; + if (!aEvent.dataTransfer.mozTypesAt(0).length) { + return; + } + + let draggedItemId = aEvent.dataTransfer.mozGetDataAt( + kDragDataTypePrefix + documentId, + 0 + ); + let draggedWrapper = document.getElementById("wrapper-" + draggedItemId); + let targetArea = this._getCustomizableParent( + aOverrideTarget || aEvent.currentTarget + ); + let originArea = this._getCustomizableParent(draggedWrapper); + + // Do nothing if the target or origin are not customizable. + if (!targetArea || !originArea) { + return; + } + + // Do nothing if the widget is not allowed to be removed. + if ( + targetArea.id == kPaletteId && + !CustomizableUI.isWidgetRemovable(draggedItemId) + ) { + return; + } + + // Do nothing if the widget is not allowed to move to the target area. + if (!CustomizableUI.canWidgetMoveToArea(draggedItemId, targetArea.id)) { + return; + } + + let targetAreaType = CustomizableUI.getPlaceForItem(targetArea); + let targetNode = this._getDragOverNode( + aEvent, + targetArea, + targetAreaType, + draggedItemId + ); + + // We need to determine the place that the widget is being dropped in + // the target. + let dragOverItem, dragValue; + if (targetNode == CustomizableUI.getCustomizationTarget(targetArea)) { + // We'll assume if the user is dragging directly over the target, that + // they're attempting to append a child to that target. + dragOverItem = + (targetAreaType == "toolbar" + ? this._findVisiblePreviousSiblingNode(targetNode.lastElementChild) + : targetNode.lastElementChild) || targetNode; + dragValue = "after"; + } else { + let targetParent = targetNode.parentNode; + let position = Array.prototype.indexOf.call( + targetParent.children, + targetNode + ); + if (position == -1) { + dragOverItem = + targetAreaType == "toolbar" + ? this._findVisiblePreviousSiblingNode(targetNode.lastElementChild) + : targetNode.lastElementChild; + dragValue = "after"; + } else { + dragOverItem = targetParent.children[position]; + if (targetAreaType == "toolbar") { + // Check if the aDraggedItem is hovered past the first half of dragOverItem + let itemRect = this._getBoundsWithoutFlushing(dragOverItem); + let dropTargetCenter = itemRect.left + itemRect.width / 2; + let existingDir = dragOverItem.getAttribute("dragover"); + let dirFactor = this.window.RTL_UI ? -1 : 1; + if (existingDir == "before") { + dropTargetCenter += + ((parseInt(dragOverItem.style.borderInlineStartWidth) || 0) / 2) * + dirFactor; + } else { + dropTargetCenter -= + ((parseInt(dragOverItem.style.borderInlineEndWidth) || 0) / 2) * + dirFactor; + } + let before = this.window.RTL_UI + ? aEvent.clientX > dropTargetCenter + : aEvent.clientX < dropTargetCenter; + dragValue = before ? "before" : "after"; + } else if (targetAreaType == "panel") { + let itemRect = this._getBoundsWithoutFlushing(dragOverItem); + let dropTargetCenter = itemRect.top + itemRect.height / 2; + let existingDir = dragOverItem.getAttribute("dragover"); + if (existingDir == "before") { + dropTargetCenter += + (parseInt(dragOverItem.style.borderBlockStartWidth) || 0) / 2; + } else { + dropTargetCenter -= + (parseInt(dragOverItem.style.borderBlockEndWidth) || 0) / 2; + } + dragValue = aEvent.clientY < dropTargetCenter ? "before" : "after"; + } else { + dragValue = "before"; + } + } + } + + if (this._dragOverItem && dragOverItem != this._dragOverItem) { + this._cancelDragActive(this._dragOverItem, dragOverItem); + } + + if ( + dragOverItem != this._dragOverItem || + dragValue != dragOverItem.getAttribute("dragover") + ) { + if (dragOverItem != CustomizableUI.getCustomizationTarget(targetArea)) { + this._setDragActive( + dragOverItem, + dragValue, + draggedItemId, + targetAreaType + ); + } + this._dragOverItem = dragOverItem; + targetArea.setAttribute("draggingover", "true"); + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + }, + + _onDragDrop(aEvent, aOverrideTarget) { + if (this._isUnwantedDragDrop(aEvent)) { + return; + } + + __dumpDragData(aEvent); + this._initializeDragAfterMove = null; + this.window.clearTimeout(this._dragInitializeTimeout); + + let targetArea = this._getCustomizableParent( + aOverrideTarget || aEvent.currentTarget + ); + let document = aEvent.target.ownerDocument; + let documentId = document.documentElement.id; + let draggedItemId = aEvent.dataTransfer.mozGetDataAt( + kDragDataTypePrefix + documentId, + 0 + ); + let draggedWrapper = document.getElementById("wrapper-" + draggedItemId); + let originArea = this._getCustomizableParent(draggedWrapper); + if (this._dragSizeMap) { + this._dragSizeMap = new WeakMap(); + } + // Do nothing if the target area or origin area are not customizable. + if (!targetArea || !originArea) { + return; + } + let targetNode = this._dragOverItem; + let dropDir = targetNode.getAttribute("dragover"); + // Need to insert *after* this node if we promised the user that: + if (targetNode != targetArea && dropDir == "after") { + if (targetNode.nextElementSibling) { + targetNode = targetNode.nextElementSibling; + } else { + targetNode = targetArea; + } + } + if (targetNode.tagName == "toolbarpaletteitem") { + targetNode = targetNode.firstElementChild; + } + + this._cancelDragActive(this._dragOverItem, null, true); + + try { + this._applyDrop( + aEvent, + targetArea, + originArea, + draggedItemId, + targetNode + ); + } catch (ex) { + lazy.log.error(ex, ex.stack); + } + + // If the user explicitly moves this item, turn off autohide. + if (draggedItemId == "downloads-button") { + Services.prefs.setBoolPref(kDownloadAutoHidePref, false); + this._showDownloadsAutoHidePanel(); + } + }, + + _applyDrop(aEvent, aTargetArea, aOriginArea, aDraggedItemId, aTargetNode) { + let document = aEvent.target.ownerDocument; + let draggedItem = document.getElementById(aDraggedItemId); + draggedItem.hidden = false; + draggedItem.removeAttribute("mousedown"); + + let toolbarParent = draggedItem.closest("toolbar"); + if (toolbarParent) { + toolbarParent.style.removeProperty("min-height"); + } + + // Do nothing if the target was dropped onto itself (ie, no change in area + // or position). + if (draggedItem == aTargetNode) { + return; + } + + if (!CustomizableUI.canWidgetMoveToArea(aDraggedItemId, aTargetArea.id)) { + return; + } + + // Is the target area the customization palette? + if (aTargetArea.id == kPaletteId) { + // Did we drag from outside the palette? + if (aOriginArea.id !== kPaletteId) { + if (!CustomizableUI.isWidgetRemovable(aDraggedItemId)) { + return; + } + + CustomizableUI.removeWidgetFromArea(aDraggedItemId, "drag"); + lazy.BrowserUsageTelemetry.recordWidgetChange( + aDraggedItemId, + null, + "drag" + ); + // Special widgets are removed outright, we can return here: + if (CustomizableUI.isSpecialWidget(aDraggedItemId)) { + return; + } + } + draggedItem = draggedItem.parentNode; + + // If the target node is the palette itself, just append + if (aTargetNode == this.visiblePalette) { + this.visiblePalette.appendChild(draggedItem); + } else { + // The items in the palette are wrapped, so we need the target node's parent here: + this.visiblePalette.insertBefore(draggedItem, aTargetNode.parentNode); + } + this._onDragEnd(aEvent); + return; + } + + // Skipintoolbarset items won't really be moved: + let areaCustomizationTarget = + CustomizableUI.getCustomizationTarget(aTargetArea); + if (draggedItem.getAttribute("skipintoolbarset") == "true") { + // These items should never leave their area: + if (aTargetArea != aOriginArea) { + return; + } + let place = draggedItem.parentNode.getAttribute("place"); + this.unwrapToolbarItem(draggedItem.parentNode); + if (aTargetNode == areaCustomizationTarget) { + areaCustomizationTarget.appendChild(draggedItem); + } else { + this.unwrapToolbarItem(aTargetNode.parentNode); + areaCustomizationTarget.insertBefore(draggedItem, aTargetNode); + this.wrapToolbarItem(aTargetNode, place); + } + this.wrapToolbarItem(draggedItem, place); + return; + } + + // Force creating a new spacer/spring/separator if dragging from the palette + if ( + CustomizableUI.isSpecialWidget(aDraggedItemId) && + aOriginArea.id == kPaletteId + ) { + aDraggedItemId = aDraggedItemId.match( + /^customizableui-special-(spring|spacer|separator)/ + )[1]; + } + + // Is the target the customization area itself? If so, we just add the + // widget to the end of the area. + if (aTargetNode == areaCustomizationTarget) { + CustomizableUI.addWidgetToArea(aDraggedItemId, aTargetArea.id); + lazy.BrowserUsageTelemetry.recordWidgetChange( + aDraggedItemId, + aTargetArea.id, + "drag" + ); + this._onDragEnd(aEvent); + return; + } + + // We need to determine the place that the widget is being dropped in + // the target. + let placement; + let itemForPlacement = aTargetNode; + // Skip the skipintoolbarset items when determining the place of the item: + while ( + itemForPlacement && + itemForPlacement.getAttribute("skipintoolbarset") == "true" && + itemForPlacement.parentNode && + itemForPlacement.parentNode.nodeName == "toolbarpaletteitem" + ) { + itemForPlacement = itemForPlacement.parentNode.nextElementSibling; + if ( + itemForPlacement && + itemForPlacement.nodeName == "toolbarpaletteitem" + ) { + itemForPlacement = itemForPlacement.firstElementChild; + } + } + if (itemForPlacement) { + let targetNodeId = + itemForPlacement.nodeName == "toolbarpaletteitem" + ? itemForPlacement.firstElementChild && + itemForPlacement.firstElementChild.id + : itemForPlacement.id; + placement = CustomizableUI.getPlacementOfWidget(targetNodeId); + } + if (!placement) { + lazy.log.debug( + "Could not get a position for " + + aTargetNode.nodeName + + "#" + + aTargetNode.id + + "." + + aTargetNode.className + ); + } + let position = placement ? placement.position : null; + + // Is the target area the same as the origin? Since we've already handled + // the possibility that the target is the customization palette, we know + // that the widget is moving within a customizable area. + if (aTargetArea == aOriginArea) { + CustomizableUI.moveWidgetWithinArea(aDraggedItemId, position); + lazy.BrowserUsageTelemetry.recordWidgetChange( + aDraggedItemId, + aTargetArea.id, + "drag" + ); + } else { + CustomizableUI.addWidgetToArea(aDraggedItemId, aTargetArea.id, position); + lazy.BrowserUsageTelemetry.recordWidgetChange( + aDraggedItemId, + aTargetArea.id, + "drag" + ); + } + + this._onDragEnd(aEvent); + + // If we dropped onto a skipintoolbarset item, manually correct the drop location: + if (aTargetNode != itemForPlacement) { + let draggedWrapper = draggedItem.parentNode; + let container = draggedWrapper.parentNode; + container.insertBefore(draggedWrapper, aTargetNode.parentNode); + } + }, + + _onDragLeave(aEvent) { + if (this._isUnwantedDragDrop(aEvent)) { + return; + } + + __dumpDragData(aEvent); + + // When leaving customization areas, cancel the drag on the last dragover item + // We've attached the listener to areas, so aEvent.currentTarget will be the area. + // We don't care about dragleave events fired on descendants of the area, + // so we check that the event's target is the same as the area to which the listener + // was attached. + if (this._dragOverItem && aEvent.target == aEvent.currentTarget) { + this._cancelDragActive(this._dragOverItem); + this._dragOverItem = null; + } + }, + + /** + * To workaround bug 460801 we manually forward the drop event here when dragend wouldn't be fired. + * + * Note that that means that this function may be called multiple times by a single drag operation. + */ + _onDragEnd(aEvent) { + if (this._isUnwantedDragDrop(aEvent)) { + return; + } + this._initializeDragAfterMove = null; + this.window.clearTimeout(this._dragInitializeTimeout); + __dumpDragData(aEvent, "_onDragEnd"); + + let document = aEvent.target.ownerDocument; + document.documentElement.removeAttribute("customizing-movingItem"); + + let documentId = document.documentElement.id; + if (!aEvent.dataTransfer.mozTypesAt(0)) { + return; + } + + let draggedItemId = aEvent.dataTransfer.mozGetDataAt( + kDragDataTypePrefix + documentId, + 0 + ); + + let draggedWrapper = document.getElementById("wrapper-" + draggedItemId); + + // DraggedWrapper might no longer available if a widget node is + // destroyed after starting (but before stopping) a drag. + if (draggedWrapper) { + draggedWrapper.hidden = false; + draggedWrapper.removeAttribute("mousedown"); + + let toolbarParent = draggedWrapper.closest("toolbar"); + if (toolbarParent) { + toolbarParent.style.removeProperty("min-height"); + } + } + + if (this._dragOverItem) { + this._cancelDragActive(this._dragOverItem); + this._dragOverItem = null; + } + lazy.DragPositionManager.stop(); + }, + + _isUnwantedDragDrop(aEvent) { + // The synthesized events for tests generated by synthesizePlainDragAndDrop + // and synthesizeDrop in mochitests are used only for testing whether the + // right data is being put into the dataTransfer. Neither cause a real drop + // to occur, so they don't set the source node. There isn't a means of + // testing real drag and drops, so this pref skips the check but it should + // only be set by test code. + if (this._skipSourceNodeCheck) { + return false; + } + + /* Discard drag events that originated from a separate window to + prevent content->chrome privilege escalations. */ + let mozSourceNode = aEvent.dataTransfer.mozSourceNode; + // mozSourceNode is null in the dragStart event handler or if + // the drag event originated in an external application. + return !mozSourceNode || mozSourceNode.ownerGlobal != this.window; + }, + + _setDragActive(aItem, aValue, aDraggedItemId, aAreaType) { + if (!aItem) { + return; + } + + if (aItem.getAttribute("dragover") != aValue) { + aItem.setAttribute("dragover", aValue); + + let window = aItem.ownerGlobal; + let draggedItem = window.document.getElementById(aDraggedItemId); + if (aAreaType == "palette") { + this._setGridDragActive(aItem, draggedItem, aValue); + } else { + let targetArea = this._getCustomizableParent(aItem); + let makeSpaceImmediately = false; + if (!gDraggingInToolbars.has(targetArea.id)) { + gDraggingInToolbars.add(targetArea.id); + let draggedWrapper = this.$("wrapper-" + aDraggedItemId); + let originArea = this._getCustomizableParent(draggedWrapper); + makeSpaceImmediately = originArea == targetArea; + } + let propertyToMeasure = aAreaType == "toolbar" ? "width" : "height"; + // Calculate width/height of the item when it'd be dropped in this position. + let borderWidth = this._getDragItemSize(aItem, draggedItem)[ + propertyToMeasure + ]; + let layoutSide = aAreaType == "toolbar" ? "Inline" : "Block"; + let prop, otherProp; + if (aValue == "before") { + prop = "border" + layoutSide + "StartWidth"; + otherProp = "border-" + layoutSide.toLowerCase() + "-end-width"; + } else { + prop = "border" + layoutSide + "EndWidth"; + otherProp = "border-" + layoutSide.toLowerCase() + "-start-width"; + } + if (makeSpaceImmediately) { + aItem.setAttribute("notransition", "true"); + } + aItem.style[prop] = borderWidth + "px"; + aItem.style.removeProperty(otherProp); + if (makeSpaceImmediately) { + // Force a layout flush: + aItem.getBoundingClientRect(); + aItem.removeAttribute("notransition"); + } + } + } + }, + _cancelDragActive(aItem, aNextItem, aNoTransition) { + let currentArea = this._getCustomizableParent(aItem); + if (!currentArea) { + return; + } + let nextArea = aNextItem ? this._getCustomizableParent(aNextItem) : null; + if (currentArea != nextArea) { + currentArea.removeAttribute("draggingover"); + } + let areaType = CustomizableUI.getAreaType(currentArea.id); + if (areaType) { + if (aNoTransition) { + aItem.setAttribute("notransition", "true"); + } + aItem.removeAttribute("dragover"); + // Remove all property values in the case that the end padding + // had been set. + aItem.style.removeProperty("border-inline-start-width"); + aItem.style.removeProperty("border-inline-end-width"); + aItem.style.removeProperty("border-block-start-width"); + aItem.style.removeProperty("border-block-end-width"); + if (aNoTransition) { + // Force a layout flush: + aItem.getBoundingClientRect(); + aItem.removeAttribute("notransition"); + } + } else { + aItem.removeAttribute("dragover"); + if (aNextItem) { + if (nextArea == currentArea) { + // No need to do anything if we're still dragging in this area: + return; + } + } + // Otherwise, clear everything out: + let positionManager = + lazy.DragPositionManager.getManagerForArea(currentArea); + positionManager.clearPlaceholders(currentArea, aNoTransition); + } + }, + + _setGridDragActive(aDragOverNode, aDraggedItem, aValue) { + let targetArea = this._getCustomizableParent(aDragOverNode); + let draggedWrapper = this.$("wrapper-" + aDraggedItem.id); + let originArea = this._getCustomizableParent(draggedWrapper); + let positionManager = + lazy.DragPositionManager.getManagerForArea(targetArea); + let draggedSize = this._getDragItemSize(aDragOverNode, aDraggedItem); + positionManager.insertPlaceholder( + targetArea, + aDragOverNode, + draggedSize, + originArea == targetArea + ); + }, + + _getDragItemSize(aDragOverNode, aDraggedItem) { + // Cache it good, cache it real good. + if (!this._dragSizeMap) { + this._dragSizeMap = new WeakMap(); + } + if (!this._dragSizeMap.has(aDraggedItem)) { + this._dragSizeMap.set(aDraggedItem, new WeakMap()); + } + let itemMap = this._dragSizeMap.get(aDraggedItem); + let targetArea = this._getCustomizableParent(aDragOverNode); + let currentArea = this._getCustomizableParent(aDraggedItem); + // Return the size for this target from cache, if it exists. + let size = itemMap.get(targetArea); + if (size) { + return size; + } + + // Calculate size of the item when it'd be dropped in this position. + let currentParent = aDraggedItem.parentNode; + let currentSibling = aDraggedItem.nextElementSibling; + const kAreaType = "cui-areatype"; + let areaType, currentType; + + if (targetArea != currentArea) { + // Move the widget temporarily next to the placeholder. + aDragOverNode.parentNode.insertBefore(aDraggedItem, aDragOverNode); + // Update the node's areaType. + areaType = CustomizableUI.getAreaType(targetArea.id); + currentType = + aDraggedItem.hasAttribute(kAreaType) && + aDraggedItem.getAttribute(kAreaType); + if (areaType) { + aDraggedItem.setAttribute(kAreaType, areaType); + } + this.wrapToolbarItem(aDraggedItem, areaType || "palette"); + CustomizableUI.onWidgetDrag(aDraggedItem.id, targetArea.id); + } else { + aDraggedItem.parentNode.hidden = false; + } + + // Fetch the new size. + let rect = aDraggedItem.parentNode.getBoundingClientRect(); + size = { width: rect.width, height: rect.height }; + // Cache the found value of size for this target. + itemMap.set(targetArea, size); + + if (targetArea != currentArea) { + this.unwrapToolbarItem(aDraggedItem.parentNode); + // Put the item back into its previous position. + currentParent.insertBefore(aDraggedItem, currentSibling); + // restore the areaType + if (areaType) { + if (currentType === false) { + aDraggedItem.removeAttribute(kAreaType); + } else { + aDraggedItem.setAttribute(kAreaType, currentType); + } + } + this.createOrUpdateWrapper(aDraggedItem, null, true); + CustomizableUI.onWidgetDrag(aDraggedItem.id); + } else { + aDraggedItem.parentNode.hidden = true; + } + return size; + }, + + _getCustomizableParent(aElement) { + if (aElement) { + // Deal with drag/drop on the padding of the panel. + let containingPanelHolder = aElement.closest( + "#customization-panelHolder" + ); + if (containingPanelHolder) { + return containingPanelHolder.querySelector( + "#widget-overflow-fixed-list" + ); + } + } + + let areas = CustomizableUI.areas; + areas.push(kPaletteId); + return aElement.closest(areas.map(a => "#" + CSS.escape(a)).join(",")); + }, + + _getDragOverNode(aEvent, aAreaElement, aAreaType, aDraggedItemId) { + let expectedParent = + CustomizableUI.getCustomizationTarget(aAreaElement) || aAreaElement; + if (!expectedParent.contains(aEvent.target)) { + return expectedParent; + } + // Offset the drag event's position with the offset to the center of + // the thing we're dragging + let dragX = aEvent.clientX - this._dragOffset.x; + let dragY = aEvent.clientY - this._dragOffset.y; + + // Ensure this is within the container + let boundsContainer = expectedParent; + let bounds = this._getBoundsWithoutFlushing(boundsContainer); + dragX = Math.min(bounds.right, Math.max(dragX, bounds.left)); + dragY = Math.min(bounds.bottom, Math.max(dragY, bounds.top)); + + let targetNode; + if (aAreaType == "toolbar" || aAreaType == "panel") { + targetNode = aAreaElement.ownerDocument.elementFromPoint(dragX, dragY); + while (targetNode && targetNode.parentNode != expectedParent) { + targetNode = targetNode.parentNode; + } + } else { + let positionManager = + lazy.DragPositionManager.getManagerForArea(aAreaElement); + // Make it relative to the container: + dragX -= bounds.left; + dragY -= bounds.top; + // Find the closest node: + targetNode = positionManager.find(aAreaElement, dragX, dragY); + } + return targetNode || aEvent.target; + }, + + _onMouseDown(aEvent) { + lazy.log.debug("_onMouseDown"); + if (aEvent.button != 0) { + return; + } + let doc = aEvent.target.ownerDocument; + doc.documentElement.setAttribute("customizing-movingItem", true); + let item = this._getWrapper(aEvent.target); + if (item) { + item.setAttribute("mousedown", "true"); + } + }, + + _onMouseUp(aEvent) { + lazy.log.debug("_onMouseUp"); + if (aEvent.button != 0) { + return; + } + let doc = aEvent.target.ownerDocument; + doc.documentElement.removeAttribute("customizing-movingItem"); + let item = this._getWrapper(aEvent.target); + if (item) { + item.removeAttribute("mousedown"); + } + }, + + _getWrapper(aElement) { + while (aElement && aElement.localName != "toolbarpaletteitem") { + if (aElement.localName == "toolbar") { + return null; + } + aElement = aElement.parentNode; + } + return aElement; + }, + + _findVisiblePreviousSiblingNode(aReferenceNode) { + while ( + aReferenceNode && + aReferenceNode.localName == "toolbarpaletteitem" && + aReferenceNode.firstElementChild.hidden + ) { + aReferenceNode = aReferenceNode.previousElementSibling; + } + return aReferenceNode; + }, + + onPaletteContextMenuShowing(event) { + let isFlexibleSpace = event.target.triggerNode.id.includes( + "wrapper-customizableui-special-spring" + ); + event.target.querySelector(".customize-context-addToPanel").disabled = + isFlexibleSpace; + }, + + onPanelContextMenuShowing(event) { + let inPermanentArea = !!event.target.triggerNode.closest( + "#widget-overflow-fixed-list" + ); + let doc = event.target.ownerDocument; + doc.getElementById("customizationPanelItemContextMenuUnpin").hidden = + !inPermanentArea; + doc.getElementById("customizationPanelItemContextMenuPin").hidden = + inPermanentArea; + + doc.ownerGlobal.MozXULElement.insertFTLIfNeeded( + "browser/toolbarContextMenu.ftl" + ); + event.target.querySelectorAll("[data-lazy-l10n-id]").forEach(el => { + el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); + el.removeAttribute("data-lazy-l10n-id"); + }); + }, + + _checkForDownloadsClick(event) { + if ( + event.target.closest("#wrapper-downloads-button") && + event.button == 0 + ) { + event.view.gCustomizeMode._showDownloadsAutoHidePanel(); + } + }, + + _setupDownloadAutoHideToggle() { + this.window.addEventListener("click", this._checkForDownloadsClick, true); + }, + + _teardownDownloadAutoHideToggle() { + this.window.removeEventListener( + "click", + this._checkForDownloadsClick, + true + ); + this.$(kDownloadAutohidePanelId).hidePopup(); + }, + + _maybeMoveDownloadsButtonToNavBar() { + // If the user toggled the autohide checkbox while the item was in the + // palette, and hasn't moved it since, move the item to the default + // location in the navbar for them. + if ( + !CustomizableUI.getPlacementOfWidget("downloads-button") && + this._moveDownloadsButtonToNavBar && + this.window.DownloadsButton.autoHideDownloadsButton + ) { + let navbarPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + let insertionPoint = navbarPlacements.indexOf("urlbar-container"); + while (++insertionPoint < navbarPlacements.length) { + let widget = navbarPlacements[insertionPoint]; + // If we find a non-searchbar, non-spacer node, break out of the loop: + if ( + widget != "search-container" && + !(CustomizableUI.isSpecialWidget(widget) && widget.includes("spring")) + ) { + break; + } + } + CustomizableUI.addWidgetToArea( + "downloads-button", + "nav-bar", + insertionPoint + ); + lazy.BrowserUsageTelemetry.recordWidgetChange( + "downloads-button", + "nav-bar", + "move-downloads" + ); + } + }, + + async _showDownloadsAutoHidePanel() { + let doc = this.document; + let panel = doc.getElementById(kDownloadAutohidePanelId); + panel.hidePopup(); + let button = doc.getElementById("downloads-button"); + // We don't show the tooltip if the button is in the panel. + if (button.closest("#widget-overflow-fixed-list")) { + return; + } + + let offsetX = 0, + offsetY = 0; + let panelOnTheLeft = false; + let toolbarContainer = button.closest("toolbar"); + if (toolbarContainer && toolbarContainer.id == "nav-bar") { + let navbarWidgets = CustomizableUI.getWidgetIdsInArea("nav-bar"); + if ( + navbarWidgets.indexOf("urlbar-container") <= + navbarWidgets.indexOf("downloads-button") + ) { + panelOnTheLeft = true; + } + } else { + await this.window.promiseDocumentFlushed(() => {}); + + if (!this._customizing || !this._wantToBeInCustomizeMode) { + return; + } + let buttonBounds = this._getBoundsWithoutFlushing(button); + let windowBounds = this._getBoundsWithoutFlushing(doc.documentElement); + panelOnTheLeft = + buttonBounds.left + buttonBounds.width / 2 > windowBounds.width / 2; + } + let position; + if (panelOnTheLeft) { + // Tested in RTL, these get inverted automatically, so this does the + // right thing without taking RTL into account explicitly. + position = "topleft topright"; + if (toolbarContainer) { + offsetX = 8; + } + } else { + position = "topright topleft"; + if (toolbarContainer) { + offsetX = -8; + } + } + + let checkbox = doc.getElementById(kDownloadAutohideCheckboxId); + if (this.window.DownloadsButton.autoHideDownloadsButton) { + checkbox.setAttribute("checked", "true"); + } else { + checkbox.removeAttribute("checked"); + } + + // We don't use the icon to anchor because it might be resizing because of + // the animations for drag/drop. Hence the use of offsets. + panel.openPopup(button, position, offsetX, offsetY); + }, + + onDownloadsAutoHideChange(event) { + let checkbox = event.target.ownerDocument.getElementById( + kDownloadAutohideCheckboxId + ); + Services.prefs.setBoolPref(kDownloadAutoHidePref, checkbox.checked); + // Ensure we move the button (back) after the user leaves customize mode. + event.view.gCustomizeMode._moveDownloadsButtonToNavBar = checkbox.checked; + }, + + customizeTouchBar() { + let updater = Cc["@mozilla.org/widget/touchbarupdater;1"].getService( + Ci.nsITouchBarUpdater + ); + updater.enterCustomizeMode(); + }, + + togglePong(enabled) { + // It's possible we're toggling for a reason other than hitting + // the button (we might be exiting, for example), so make sure that + // the state and checkbox are in sync. + let whimsyButton = this.$("whimsy-button"); + whimsyButton.checked = enabled; + + if (enabled) { + this.visiblePalette.setAttribute("whimsypong", "true"); + this.pongArena.hidden = false; + if (!this.uninitWhimsy) { + this.uninitWhimsy = this.whimsypong(); + } + } else { + this.visiblePalette.removeAttribute("whimsypong"); + if (this.uninitWhimsy) { + this.uninitWhimsy(); + this.uninitWhimsy = null; + } + this.pongArena.hidden = true; + } + }, + + whimsypong() { + function update() { + updateBall(); + updatePlayers(); + } + + function updateBall() { + if (ball[1] <= 0 || ball[1] >= gameSide) { + if ( + (ball[1] <= 0 && (ball[0] < p1 || ball[0] > p1 + paddleWidth)) || + (ball[1] >= gameSide && (ball[0] < p2 || ball[0] > p2 + paddleWidth)) + ) { + updateScore(ball[1] <= 0 ? 0 : 1); + } else { + if ( + (ball[1] <= 0 && + (ball[0] - p1 < paddleEdge || + p1 + paddleWidth - ball[0] < paddleEdge)) || + (ball[1] >= gameSide && + (ball[0] - p2 < paddleEdge || + p2 + paddleWidth - ball[0] < paddleEdge)) + ) { + ballDxDy[0] *= Math.random() + 1.3; + ballDxDy[0] = Math.max(Math.min(ballDxDy[0], 6), -6); + if (Math.abs(ballDxDy[0]) == 6) { + ballDxDy[0] += Math.sign(ballDxDy[0]) * Math.random(); + } + } else { + ballDxDy[0] /= 1.1; + } + ballDxDy[1] *= -1; + ball[1] = ball[1] <= 0 ? 0 : gameSide; + } + } + ball = [ + Math.max(Math.min(ball[0] + ballDxDy[0], gameSide), 0), + Math.max(Math.min(ball[1] + ballDxDy[1], gameSide), 0), + ]; + if (ball[0] <= 0 || ball[0] >= gameSide) { + ballDxDy[0] *= -1; + } + } + + function updatePlayers() { + if (keydown) { + let p1Adj = 1; + if ( + (keydown == 37 && !window.RTL_UI) || + (keydown == 39 && window.RTL_UI) + ) { + p1Adj = -1; + } + p1 += p1Adj * 10 * keydownAdj; + } + + let sign = Math.sign(ballDxDy[0]); + if ( + (sign > 0 && ball[0] > p2 + paddleWidth / 2) || + (sign < 0 && ball[0] < p2 + paddleWidth / 2) + ) { + p2 += sign * 3; + } else if ( + (sign > 0 && ball[0] > p2 + paddleWidth / 1.1) || + (sign < 0 && ball[0] < p2 + paddleWidth / 1.1) + ) { + p2 += sign * 9; + } + + if (score >= winScore) { + p1 = ball[0]; + p2 = ball[0]; + } + p1 = Math.max(Math.min(p1, gameSide - paddleWidth), 0); + p2 = Math.max(Math.min(p2, gameSide - paddleWidth), 0); + } + + function updateScore(adj) { + if (adj) { + score += adj; + } else if (--lives == 0) { + quit = true; + } + ball = ballDef.slice(); + ballDxDy = ballDxDyDef.slice(); + ballDxDy[1] *= score / winScore + 1; + } + + function draw() { + let xAdj = window.RTL_UI ? -1 : 1; + elements["wp-player1"].style.transform = + "translate(" + xAdj * p1 + "px, -37px)"; + elements["wp-player2"].style.transform = + "translate(" + xAdj * p2 + "px, " + gameSide + "px)"; + elements["wp-ball"].style.transform = + "translate(" + xAdj * ball[0] + "px, " + ball[1] + "px)"; + elements["wp-score"].textContent = score; + elements["wp-lives"].setAttribute("lives", lives); + if (score >= winScore) { + let arena = elements.arena; + let image = "url(chrome://browser/skin/customizableui/whimsy.png)"; + let position = `${ + (window.RTL_UI ? gameSide : 0) + xAdj * ball[0] - 10 + }px ${ball[1] - 10}px`; + let repeat = "no-repeat"; + let size = "20px"; + if (arena.style.backgroundImage) { + if (arena.style.backgroundImage.split(",").length >= 160) { + quit = true; + } + + image += ", " + arena.style.backgroundImage; + position += ", " + arena.style.backgroundPosition; + repeat += ", " + arena.style.backgroundRepeat; + size += ", " + arena.style.backgroundSize; + } + arena.style.backgroundImage = image; + arena.style.backgroundPosition = position; + arena.style.backgroundRepeat = repeat; + arena.style.backgroundSize = size; + } + } + + function onkeydown(event) { + keys.push(event.which); + if (keys.length > 10) { + keys.shift(); + let codeEntered = true; + for (let i = 0; i < keys.length; i++) { + if (keys[i] != keysCode[i]) { + codeEntered = false; + break; + } + } + if (codeEntered) { + elements.arena.setAttribute("kcode", "true"); + let spacer = document.querySelector( + "#customization-palette > toolbarpaletteitem" + ); + spacer.setAttribute("kcode", "true"); + } + } + if (event.which == 37 /* left */ || event.which == 39 /* right */) { + keydown = event.which; + keydownAdj *= 1.05; + } + } + + function onkeyup(event) { + if (event.which == 37 || event.which == 39) { + keydownAdj = 1; + keydown = 0; + } + } + + function uninit() { + document.removeEventListener("keydown", onkeydown); + document.removeEventListener("keyup", onkeyup); + if (rAFHandle) { + window.cancelAnimationFrame(rAFHandle); + } + let arena = elements.arena; + while (arena.firstChild) { + arena.firstChild.remove(); + } + arena.removeAttribute("score"); + arena.removeAttribute("lives"); + arena.removeAttribute("kcode"); + arena.style.removeProperty("background-image"); + arena.style.removeProperty("background-position"); + arena.style.removeProperty("background-repeat"); + arena.style.removeProperty("background-size"); + let spacer = document.querySelector( + "#customization-palette > toolbarpaletteitem" + ); + spacer.removeAttribute("kcode"); + elements = null; + document = null; + quit = true; + } + + if (this.uninitWhimsy) { + return this.uninitWhimsy; + } + + let ballDef = [10, 10]; + let ball = [10, 10]; + let ballDxDyDef = [2, 2]; + let ballDxDy = [2, 2]; + let score = 0; + let p1 = 0; + let p2 = 10; + let gameSide = 300; + let paddleEdge = 30; + let paddleWidth = 84; + let keydownAdj = 1; + let keydown = 0; + let keys = []; + let keysCode = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; + let lives = 5; + let winScore = 11; + let quit = false; + let document = this.document; + let rAFHandle = 0; + let elements = { + arena: document.getElementById("customization-pong-arena"), + }; + + document.addEventListener("keydown", onkeydown); + document.addEventListener("keyup", onkeyup); + + for (let id of ["player1", "player2", "ball", "score", "lives"]) { + let el = document.createXULElement("box"); + el.id = "wp-" + id; + elements[el.id] = elements.arena.appendChild(el); + } + + let spacer = this.visiblePalette.querySelector("toolbarpaletteitem"); + for (let player of ["#wp-player1", "#wp-player2"]) { + let val = "-moz-element(#" + spacer.id + ") no-repeat"; + elements.arena.querySelector(player).style.background = val; + } + + let window = this.window; + rAFHandle = window.requestAnimationFrame(function animate() { + update(); + draw(); + if (quit) { + elements["wp-score"].textContent = score; + elements["wp-lives"] && + elements["wp-lives"].setAttribute("lives", lives); + elements.arena.setAttribute("score", score); + elements.arena.setAttribute("lives", lives); + } else { + rAFHandle = window.requestAnimationFrame(animate); + } + }); + + return uninit; + }, +}; + +function __dumpDragData(aEvent, caller) { + if (!gDebug) { + return; + } + let str = + "Dumping drag data (" + + (caller ? caller + " in " : "") + + "CustomizeMode.sys.mjs) {\n"; + str += " type: " + aEvent.type + "\n"; + for (let el of ["target", "currentTarget", "relatedTarget"]) { + if (aEvent[el]) { + str += + " " + + el + + ": " + + aEvent[el] + + "(localName=" + + aEvent[el].localName + + "; id=" + + aEvent[el].id + + ")\n"; + } + } + for (let prop in aEvent.dataTransfer) { + if (typeof aEvent.dataTransfer[prop] != "function") { + str += + " dataTransfer[" + prop + "]: " + aEvent.dataTransfer[prop] + "\n"; + } + } + str += "}"; + lazy.log.debug(str); +} + +function dispatchFunction(aFunc) { + Services.tm.dispatchToMainThread(aFunc); +} diff --git a/browser/components/customizableui/DragPositionManager.sys.mjs b/browser/components/customizableui/DragPositionManager.sys.mjs new file mode 100644 index 0000000000..dede3f94b1 --- /dev/null +++ b/browser/components/customizableui/DragPositionManager.sys.mjs @@ -0,0 +1,313 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var gManagers = new WeakMap(); + +const kPaletteId = "customization-palette"; + +function AreaPositionManager(aContainer) { + // Caching the direction and bounds of the container for quick access later: + this._rtl = aContainer.ownerGlobal.RTL_UI; + let containerRect = aContainer.getBoundingClientRect(); + this._containerInfo = { + left: containerRect.left, + right: containerRect.right, + top: containerRect.top, + width: containerRect.width, + }; + this._horizontalDistance = null; + this.update(aContainer); +} + +AreaPositionManager.prototype = { + _nodePositionStore: null, + + update(aContainer) { + this._nodePositionStore = new WeakMap(); + let last = null; + let singleItemHeight; + for (let child of aContainer.children) { + if (child.hidden) { + continue; + } + let coordinates = this._lazyStoreGet(child); + // We keep a baseline horizontal distance between nodes around + // for use when we can't compare with previous/next nodes + if (!this._horizontalDistance && last) { + this._horizontalDistance = coordinates.left - last.left; + } + // We also keep the basic height of items for use below: + if (!singleItemHeight) { + singleItemHeight = coordinates.height; + } + last = coordinates; + } + this._heightToWidthFactor = this._containerInfo.width / singleItemHeight; + }, + + /** + * Find the closest node in the container given the coordinates. + * "Closest" is defined in a somewhat strange manner: we prefer nodes + * which are in the same row over nodes that are in a different row. + * In order to implement this, we use a weighted cartesian distance + * where dy is more heavily weighted by a factor corresponding to the + * ratio between the container's width and the height of its elements. + */ + find(aContainer, aX, aY) { + let closest = null; + let minCartesian = Number.MAX_VALUE; + let containerX = this._containerInfo.left; + let containerY = this._containerInfo.top; + for (let node of aContainer.children) { + let coordinates = this._lazyStoreGet(node); + let offsetX = coordinates.x - containerX; + let offsetY = coordinates.y - containerY; + let hDiff = offsetX - aX; + let vDiff = offsetY - aY; + // Then compensate for the height/width ratio so that we prefer items + // which are in the same row: + hDiff /= this._heightToWidthFactor; + + let cartesianDiff = hDiff * hDiff + vDiff * vDiff; + if (cartesianDiff < minCartesian) { + minCartesian = cartesianDiff; + closest = node; + } + } + + // Now correct this node based on what we're dragging + if (closest) { + let targetBounds = this._lazyStoreGet(closest); + let farSide = this._rtl ? "left" : "right"; + let outsideX = targetBounds[farSide]; + // Check if we're closer to the next target than to this one: + // Only move if we're not targeting a node in a different row: + if (aY > targetBounds.top && aY < targetBounds.bottom) { + if ((!this._rtl && aX > outsideX) || (this._rtl && aX < outsideX)) { + return closest.nextElementSibling || aContainer; + } + } + } + return closest; + }, + + /** + * "Insert" a "placeholder" by shifting the subsequent children out of the + * way. We go through all the children, and shift them based on the position + * they would have if we had inserted something before aBefore. We use CSS + * transforms for this, which are CSS transitioned. + */ + insertPlaceholder(aContainer, aBefore, aSize, aIsFromThisArea) { + let isShifted = false; + for (let child of aContainer.children) { + // Don't need to shift hidden nodes: + if (child.hidden) { + continue; + } + // If this is the node before which we're inserting, start shifting + // everything that comes after. One exception is inserting at the end + // of the menupanel, in which case we do not shift the placeholders: + if (child == aBefore) { + isShifted = true; + } + if (isShifted) { + if (aIsFromThisArea && !this._lastPlaceholderInsertion) { + child.setAttribute("notransition", "true"); + } + // Determine the CSS transform based on the next node: + child.style.transform = this._diffWithNext(child, aSize); + } else { + // If we're not shifting this node, reset the transform + child.style.transform = ""; + } + } + if ( + aContainer.lastElementChild && + aIsFromThisArea && + !this._lastPlaceholderInsertion + ) { + // Flush layout: + aContainer.lastElementChild.getBoundingClientRect(); + // then remove all the [notransition] + for (let child of aContainer.children) { + child.removeAttribute("notransition"); + } + } + this._lastPlaceholderInsertion = aBefore; + }, + + /** + * Reset all the transforms in this container, optionally without + * transitioning them. + * @param aContainer the container in which to reset transforms + * @param aNoTransition if truthy, adds a notransition attribute to the node + * while resetting the transform. + */ + clearPlaceholders(aContainer, aNoTransition) { + for (let child of aContainer.children) { + if (aNoTransition) { + child.setAttribute("notransition", true); + } + child.style.transform = ""; + if (aNoTransition) { + // Need to force a reflow otherwise this won't work. + child.getBoundingClientRect(); + child.removeAttribute("notransition"); + } + } + // We snapped back, so we can assume there's no more + // "last" placeholder insertion point to keep track of. + if (aNoTransition) { + this._lastPlaceholderInsertion = null; + } + }, + + _diffWithNext(aNode, aSize) { + let xDiff; + let yDiff = null; + let nodeBounds = this._lazyStoreGet(aNode); + let side = this._rtl ? "right" : "left"; + let next = this._getVisibleSiblingForDirection(aNode, "next"); + // First we determine the transform along the x axis. + // Usually, there will be a next node to base this on: + if (next) { + let otherBounds = this._lazyStoreGet(next); + xDiff = otherBounds[side] - nodeBounds[side]; + // We set this explicitly because otherwise some strange difference + // between the height and the actual difference between line creeps in + // and messes with alignments + yDiff = otherBounds.top - nodeBounds.top; + } else { + // We don't have a sibling whose position we can use. First, let's see + // if we're also the first item (which complicates things): + let firstNode = this._firstInRow(aNode); + if (aNode == firstNode) { + // Maybe we stored the horizontal distance between nodes, + // if not, we'll use the width of the incoming node as a proxy: + xDiff = this._horizontalDistance || (this._rtl ? -1 : 1) * aSize.width; + } else { + // If not, we should be able to get the distance to the previous node + // and use the inverse, unless there's no room for another node (ie we + // are the last node and there's no room for another one) + xDiff = this._moveNextBasedOnPrevious(aNode, nodeBounds, firstNode); + } + } + + // If we've not determined the vertical difference yet, check it here + if (yDiff === null) { + // If the next node is behind rather than in front, we must have moved + // vertically: + if ((xDiff > 0 && this._rtl) || (xDiff < 0 && !this._rtl)) { + yDiff = aSize.height; + } else { + // Otherwise, we haven't + yDiff = 0; + } + } + return "translate(" + xDiff + "px, " + yDiff + "px)"; + }, + + /** + * Helper function to find the transform a node if there isn't a next node + * to base that on. + * @param aNode the node to transform + * @param aNodeBounds the bounding rect info of this node + * @param aFirstNodeInRow the first node in aNode's row + */ + _moveNextBasedOnPrevious(aNode, aNodeBounds, aFirstNodeInRow) { + let next = this._getVisibleSiblingForDirection(aNode, "previous"); + let otherBounds = this._lazyStoreGet(next); + let side = this._rtl ? "right" : "left"; + let xDiff = aNodeBounds[side] - otherBounds[side]; + // If, however, this means we move outside the container's box + // (i.e. the row in which this item is placed is full) + // we should move it to align with the first item in the next row instead + let bound = this._containerInfo[this._rtl ? "left" : "right"]; + if ( + (!this._rtl && xDiff + aNodeBounds.right > bound) || + (this._rtl && xDiff + aNodeBounds.left < bound) + ) { + xDiff = this._lazyStoreGet(aFirstNodeInRow)[side] - aNodeBounds[side]; + } + return xDiff; + }, + + /** + * Get position details from our cache. If the node is not yet cached, get its position + * information and cache it now. + * @param aNode the node whose position info we want + * @return the position info + */ + _lazyStoreGet(aNode) { + let rect = this._nodePositionStore.get(aNode); + if (!rect) { + // getBoundingClientRect() returns a DOMRect that is live, meaning that + // as the element moves around, the rects values change. We don't want + // that - we want a snapshot of what the rect values are right at this + // moment, and nothing else. So we have to clone the values. + let clientRect = aNode.getBoundingClientRect(); + rect = { + left: clientRect.left, + right: clientRect.right, + width: clientRect.width, + height: clientRect.height, + top: clientRect.top, + bottom: clientRect.bottom, + }; + rect.x = rect.left + rect.width / 2; + rect.y = rect.top + rect.height / 2; + Object.freeze(rect); + this._nodePositionStore.set(aNode, rect); + } + return rect; + }, + + _firstInRow(aNode) { + // XXXmconley: I'm not entirely sure why we need to take the floor of these + // values - it looks like, periodically, we're getting fractional pixels back + // from lazyStoreGet. I've filed bug 994247 to investigate. + let bound = Math.floor(this._lazyStoreGet(aNode).top); + let rv = aNode; + let prev; + while (rv && (prev = this._getVisibleSiblingForDirection(rv, "previous"))) { + if (Math.floor(this._lazyStoreGet(prev).bottom) <= bound) { + return rv; + } + rv = prev; + } + return rv; + }, + + _getVisibleSiblingForDirection(aNode, aDirection) { + let rv = aNode; + do { + rv = rv[aDirection + "ElementSibling"]; + } while (rv && rv.hidden); + return rv; + }, +}; + +export var DragPositionManager = { + start(aWindow) { + let areas = [aWindow.document.getElementById(kPaletteId)]; + for (let areaNode of areas) { + let positionManager = gManagers.get(areaNode); + if (positionManager) { + positionManager.update(areaNode); + } else { + gManagers.set(areaNode, new AreaPositionManager(areaNode)); + } + } + }, + + stop() { + gManagers = new WeakMap(); + }, + + getManagerForArea(aArea) { + return gManagers.get(aArea); + }, +}; + +Object.freeze(DragPositionManager); diff --git a/browser/components/customizableui/PanelMultiView.sys.mjs b/browser/components/customizableui/PanelMultiView.sys.mjs new file mode 100644 index 0000000000..a97889f08a --- /dev/null +++ b/browser/components/customizableui/PanelMultiView.sys.mjs @@ -0,0 +1,1894 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Allows a popup panel to host multiple subviews. The main view shown when the + * panel is opened may slide out to display a subview, which in turn may lead to + * other subviews in a cascade menu pattern. + * + * The <panel> element should contain a <panelmultiview> element. Views are + * declared using <panelview> elements that are usually children of the main + * <panelmultiview> element, although they don't need to be, as views can also + * be imported into the panel from other panels or popup sets. + * + * The panel should be opened asynchronously using the openPopup static method + * on the PanelMultiView object. This will display the view specified using the + * mainViewId attribute on the contained <panelmultiview> element. + * + * Specific subviews can slide in using the showSubView method, and backwards + * navigation can be done using the goBack method or through a button in the + * subview headers. + * + * The process of displaying the main view or a new subview requires multiple + * steps to be completed, hence at any given time the <panelview> element may + * be in different states: + * + * -- Open or closed + * + * All the <panelview> elements start "closed", meaning that they are not + * associated to a <panelmultiview> element and can be located anywhere in + * the document. When the openPopup or showSubView methods are called, the + * relevant view becomes "open" and the <panelview> element may be moved to + * ensure it is a descendant of the <panelmultiview> element. + * + * The "ViewShowing" event is fired at this point, when the view is not + * visible yet. The event is allowed to cancel the operation, in which case + * the view is closed immediately. + * + * Closing the view does not move the node back to its original position. + * + * -- Visible or invisible + * + * This indicates whether the view is visible in the document from a layout + * perspective, regardless of whether it is currently scrolled into view. In + * fact, all subviews are already visible before they start sliding in. + * + * Before scrolling into view, a view may become visible but be placed in a + * special off-screen area of the document where layout and measurements can + * take place asyncronously. + * + * When navigating forward, an open view may become invisible but stay open + * after sliding out of view. The last known size of these views is still + * taken into account for determining the overall panel size. + * + * When navigating backwards, an open subview will first become invisible and + * then will be closed. + * + * -- Active or inactive + * + * This indicates whether the view is fully scrolled into the visible area + * and ready to receive mouse and keyboard events. An active view is always + * visible, but a visible view may be inactive. For example, during a scroll + * transition, both views will be inactive. + * + * When a view becomes active, the ViewShown event is fired synchronously, + * and the showSubView and goBack methods can be called for navigation. + * + * For the main view of the panel, the ViewShown event is dispatched during + * the "popupshown" event, which means that other "popupshown" handlers may + * be called before the view is active. Thus, code that needs to perform + * further navigation automatically should either use the ViewShown event or + * wait for an event loop tick, like BrowserTestUtils.waitForEvent does. + * + * -- Navigating with the keyboard + * + * An open view may keep state related to keyboard navigation, even if it is + * invisible. When a view is closed, keyboard navigation state is cleared. + * + * This diagram shows how <panelview> nodes move during navigation: + * + * In this <panelmultiview> In other panels Action + * ┌───┬───┬───┐ ┌───┬───┐ + * │(A)│ B │ C │ │ D │ E │ Open panel + * └───┴───┴───┘ └───┴───┘ + * ┌───┬───┬───┐ ┌───┬───┐ + * │{A}│(C)│ B │ │ D │ E │ Show subview C + * └───┴───┴───┘ └───┴───┘ + * ┌───┬───┬───┬───┐ ┌───┐ + * │{A}│{C}│(D)│ B │ │ E │ Show subview D + * └───┴───┴───┴───┘ └───┘ + * │ ┌───┬───┬───┬───┐ ┌───┐ + * │ │{A}│(C)│ D │ B │ │ E │ Go back + * │ └───┴───┴───┴───┘ └───┘ + * │ │ │ + * │ │ └── Currently visible view + * │ │ │ + * └───┴───┴── Open views + */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gBundle", function () { + return Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); +}); + +/** + * Safety timeout after which asynchronous events will be canceled if any of the + * registered blockers does not return. + */ +const BLOCKERS_TIMEOUT_MS = 10000; + +const TRANSITION_PHASES = Object.freeze({ + START: 1, + PREPARE: 2, + TRANSITION: 3, +}); + +let gNodeToObjectMap = new WeakMap(); +let gWindowsWithUnloadHandler = new WeakSet(); + +/** + * Allows associating an object to a node lazily using a weak map. + * + * Classes deriving from this one may be easily converted to Custom Elements, + * although they would lose the ability of being associated lazily. + */ +var AssociatedToNode = class { + constructor(node) { + /** + * Node associated to this object. + */ + this.node = node; + + /** + * This promise is resolved when the current set of blockers set by event + * handlers have all been processed. + */ + this._blockersPromise = Promise.resolve(); + } + + /** + * Retrieves the instance associated with the given node, constructing a new + * one if necessary. When the last reference to the node is released, the + * object instance will be garbage collected as well. + */ + static forNode(node) { + let associatedToNode = gNodeToObjectMap.get(node); + if (!associatedToNode) { + associatedToNode = new this(node); + gNodeToObjectMap.set(node, associatedToNode); + } + return associatedToNode; + } + + get document() { + return this.node.ownerDocument; + } + + get window() { + return this.node.ownerGlobal; + } + + _getBoundsWithoutFlushing(element) { + return this.window.windowUtils.getBoundsWithoutFlushing(element); + } + + /** + * Dispatches a custom event on this element. + * + * @param {String} eventName Name of the event to dispatch. + * @param {Object} [detail] Event detail object. Optional. + * @param {Boolean} cancelable If the event can be canceled. + * @return {Boolean} `true` if the event was canceled by an event handler, `false` + * otherwise. + */ + dispatchCustomEvent(eventName, detail, cancelable = false) { + let event = new this.window.CustomEvent(eventName, { + detail, + bubbles: true, + cancelable, + }); + this.node.dispatchEvent(event); + return event.defaultPrevented; + } + + /** + * Dispatches a custom event on this element and waits for any blocking + * promises registered using the "addBlocker" function on the details object. + * If this function is called again, the event is only dispatched after all + * the previously registered blockers have returned. + * + * The event can be canceled either by resolving any blocking promise to the + * boolean value "false" or by calling preventDefault on the event. Rejections + * and exceptions will be reported and will cancel the event. + * + * Blocking should be used sporadically because it slows down the interface. + * Also, non-reentrancy is not strictly guaranteed because a safety timeout of + * BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled. + * This helps to prevent deadlocks if any of the event handlers does not + * resolve a blocker promise. + * + * @note Since there is no use case for dispatching different asynchronous + * events in parallel for the same element, this function will also wait + * for previous blockers when the event name is different. + * + * @param eventName + * Name of the custom event to dispatch. + * + * @resolves True if the event was canceled by a handler, false otherwise. + */ + async dispatchAsyncEvent(eventName) { + // Wait for all the previous blockers before dispatching the event. + let blockersPromise = this._blockersPromise.catch(() => {}); + return (this._blockersPromise = blockersPromise.then(async () => { + let blockers = new Set(); + let cancel = this.dispatchCustomEvent( + eventName, + { + addBlocker(promise) { + // Any exception in the blocker will cancel the operation. + blockers.add( + promise.catch(ex => { + console.error(ex); + return true; + }) + ); + }, + }, + true + ); + if (blockers.size) { + let timeoutPromise = new Promise((resolve, reject) => { + this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS); + }); + try { + let results = await Promise.race([ + Promise.all(blockers), + timeoutPromise, + ]); + cancel = cancel || results.some(result => result === false); + } catch (ex) { + console.error( + new Error(`One of the blockers for ${eventName} timed out.`) + ); + return true; + } + } + return cancel; + })); + } +}; + +/** + * This is associated to <panelmultiview> elements. + */ +export var PanelMultiView = class extends AssociatedToNode { + /** + * Tries to open the specified <panel> and displays the main view specified + * with the "mainViewId" attribute on the <panelmultiview> node it contains. + * + * If the panel does not contain a <panelmultiview>, it is opened directly. + * This allows consumers like page actions to accept different panel types. + * + * @see The non-static openPopup method for details. + */ + static async openPopup(panelNode, ...args) { + let panelMultiViewNode = panelNode.querySelector("panelmultiview"); + if (panelMultiViewNode) { + return this.forNode(panelMultiViewNode).openPopup(...args); + } + panelNode.openPopup(...args); + return true; + } + + /** + * Closes the specified <panel> which contains a <panelmultiview> node. + * + * If the panel does not contain a <panelmultiview>, it is closed directly. + * This allows consumers like page actions to accept different panel types. + * + * @param {DOMNode} panelNode The <panel> node. + * @param {Boolean} [animate] Whether to show a fade animation. Optional. + * + * @see The non-static hidePopup method for details. + */ + static hidePopup(panelNode, animate = false) { + let panelMultiViewNode = panelNode.querySelector("panelmultiview"); + if (panelMultiViewNode) { + this.forNode(panelMultiViewNode).hidePopup(animate); + } else { + panelNode.hidePopup(animate); + } + } + + /** + * Removes the specified <panel> from the document, ensuring that any + * <panelmultiview> node it contains is destroyed properly. + * + * If the viewCacheId attribute is present on the <panelmultiview> element, + * imported subviews will be moved out again to the element it specifies, so + * that the panel element can be removed safely. + * + * If the panel does not contain a <panelmultiview>, it is removed directly. + * This allows consumers like page actions to accept different panel types. + */ + static removePopup(panelNode) { + try { + let panelMultiViewNode = panelNode.querySelector("panelmultiview"); + if (panelMultiViewNode) { + let panelMultiView = this.forNode(panelMultiViewNode); + panelMultiView._moveOutKids(); + panelMultiView.disconnect(); + } + } finally { + // Make sure to remove the panel element even if disconnecting fails. + panelNode.remove(); + } + } + /** + * Returns the element with the given id. + * For nodes that are lazily loaded and not yet in the DOM, the node should + * be retrieved from the view cache template. + */ + static getViewNode(doc, id) { + let viewCacheTemplate = doc.getElementById("appMenu-viewCache"); + + return ( + doc.getElementById(id) || + viewCacheTemplate?.content.querySelector("#" + id) + ); + } + + /** + * Ensures that when the specified window is closed all the <panelmultiview> + * node it contains are destroyed properly. + */ + static ensureUnloadHandlerRegistered(window) { + if (gWindowsWithUnloadHandler.has(window)) { + return; + } + + window.addEventListener( + "unload", + () => { + for (let panelMultiViewNode of window.document.querySelectorAll( + "panelmultiview" + )) { + this.forNode(panelMultiViewNode).disconnect(); + } + }, + { once: true } + ); + + gWindowsWithUnloadHandler.add(window); + } + + get _panel() { + return this.node.parentNode; + } + + set _transitioning(val) { + if (val) { + this.node.setAttribute("transitioning", "true"); + } else { + this.node.removeAttribute("transitioning"); + } + } + + get _screenManager() { + if (this.__screenManager) { + return this.__screenManager; + } + return (this.__screenManager = Cc[ + "@mozilla.org/gfx/screenmanager;1" + ].getService(Ci.nsIScreenManager)); + } + + constructor(node) { + super(node); + this._openPopupPromise = Promise.resolve(false); + } + + connect() { + this.connected = true; + + PanelMultiView.ensureUnloadHandlerRegistered(this.window); + + let viewContainer = (this._viewContainer = + this.document.createXULElement("box")); + viewContainer.classList.add("panel-viewcontainer"); + + let viewStack = (this._viewStack = this.document.createXULElement("box")); + viewStack.classList.add("panel-viewstack"); + viewContainer.append(viewStack); + + let offscreenViewContainer = this.document.createXULElement("box"); + offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen"); + + let offscreenViewStack = (this._offscreenViewStack = + this.document.createXULElement("box")); + offscreenViewStack.classList.add("panel-viewstack"); + offscreenViewContainer.append(offscreenViewStack); + + this.node.prepend(offscreenViewContainer); + this.node.prepend(viewContainer); + + this.openViews = []; + + this._panel.addEventListener("popupshowing", this); + this._panel.addEventListener("popuppositioned", this); + this._panel.addEventListener("popuphidden", this); + this._panel.addEventListener("popupshown", this); + + // Proxy these public properties and methods, as used elsewhere by various + // parts of the browser, to this instance. + ["goBack", "showSubView"].forEach(method => { + Object.defineProperty(this.node, method, { + enumerable: true, + value: (...args) => this[method](...args), + }); + }); + } + + disconnect() { + // Guard against re-entrancy. + if (!this.node || !this.connected) { + return; + } + + this._panel.removeEventListener("mousemove", this); + this._panel.removeEventListener("popupshowing", this); + this._panel.removeEventListener("popuppositioned", this); + this._panel.removeEventListener("popupshown", this); + this._panel.removeEventListener("popuphidden", this); + this.document.documentElement.removeEventListener("keydown", this, true); + this.node = + this._openPopupPromise = + this._openPopupCancelCallback = + this._viewContainer = + this._viewStack = + this._transitionDetails = + null; + } + + /** + * Tries to open the panel associated with this PanelMultiView, and displays + * the main view specified with the "mainViewId" attribute. + * + * The hidePopup method can be called while the operation is in progress to + * prevent the panel from being displayed. View events may also cancel the + * operation, so there is no guarantee that the panel will become visible. + * + * The "popuphidden" event will be fired either when the operation is canceled + * or when the popup is closed later. This event can be used for example to + * reset the "open" state of the anchor or tear down temporary panels. + * + * If this method is called again before the panel is shown, the result + * depends on the operation currently in progress. If the operation was not + * canceled, the panel is opened using the arguments from the previous call, + * and this call is ignored. If the operation was canceled, it will be + * retried again using the arguments from this call. + * + * It's not necessary for the <panelmultiview> binding to be connected when + * this method is called, but the containing panel must have its display + * turned on, for example it shouldn't have the "hidden" attribute. + * + * @param anchor + * The node to anchor the popup to. + * @param options + * Either options to use or a string position. This is forwarded to + * the openPopup method of the panel. + * @param args + * Additional arguments to be forwarded to the openPopup method of the + * panel. + * + * @resolves With true as soon as the request to display the panel has been + * sent, or with false if the operation was canceled. The state of + * the panel at this point is not guaranteed. It may be still + * showing, completely shown, or completely hidden. + * @rejects If an exception is thrown at any point in the process before the + * request to display the panel is sent. + */ + async openPopup(anchor, options, ...args) { + // Set up the function that allows hidePopup or a second call to showPopup + // to cancel the specific panel opening operation that we're starting below. + // This function must be synchronous, meaning we can't use Promise.race, + // because hidePopup wants to dispatch the "popuphidden" event synchronously + // even if the panel has not been opened yet. + let canCancel = true; + let cancelCallback = (this._openPopupCancelCallback = () => { + // If the cancel callback is called and the panel hasn't been prepared + // yet, cancel showing it. Setting canCancel to false will prevent the + // popup from opening. If the panel has opened by the time the cancel + // callback is called, canCancel will be false already, and we will not + // fire the "popuphidden" event. + if (canCancel && this.node) { + canCancel = false; + this.dispatchCustomEvent("popuphidden"); + } + if (cancelCallback == this._openPopupCancelCallback) { + // If still current, let go of the cancel callback since it will capture + // the entire scope and tie it to the main window. + delete this._openPopupCancelCallback; + } + }); + + // Create a promise that is resolved with the result of the last call to + // this method, where errors indicate that the panel was not opened. + let openPopupPromise = this._openPopupPromise.catch(() => { + return false; + }); + + // Make the preparation done before showing the panel non-reentrant. The + // promise created here will be resolved only after the panel preparation is + // completed, even if a cancellation request is received in the meantime. + return (this._openPopupPromise = openPopupPromise.then(async wasShown => { + // The panel may have been destroyed in the meantime. + if (!this.node) { + return false; + } + // If the panel has been already opened there is nothing more to do. We + // check the actual state of the panel rather than setting some state in + // our handler of the "popuphidden" event because this has a lower chance + // of locking indefinitely if events aren't raised in the expected order. + if (wasShown && ["open", "showing"].includes(this._panel.state)) { + if (cancelCallback == this._openPopupCancelCallback) { + // If still current, let go of the cancel callback since it will + // capture the entire scope and tie it to the main window. + delete this._openPopupCancelCallback; + } + return true; + } + try { + if (!this.connected) { + this.connect(); + } + // Allow any of the ViewShowing handlers to prevent showing the main view. + if (!(await this._showMainView())) { + cancelCallback(); + } + } catch (ex) { + cancelCallback(); + throw ex; + } + // If a cancellation request was received there is nothing more to do. + if (!canCancel || !this.node) { + return false; + } + // We have to set canCancel to false before opening the popup because the + // hidePopup method of PanelMultiView can be re-entered by event handlers. + // If the openPopup call fails, however, we still have to dispatch the + // "popuphidden" event even if canCancel was set to false. + try { + canCancel = false; + this._panel.openPopup(anchor, options, ...args); + if (cancelCallback == this._openPopupCancelCallback) { + // If still current, let go of the cancel callback since it will + // capture the entire scope and tie it to the main window. + delete this._openPopupCancelCallback; + } + // Set an attribute on the popup to let consumers style popup elements - + // for example, the anchor arrow is styled to match the color of the header + // in the Protections Panel main view. + this._panel.setAttribute("mainviewshowing", true); + + // On Windows, if another popup is hiding while we call openPopup, the + // call won't fail but the popup won't open. In this case, we have to + // dispatch an artificial "popuphidden" event to reset our state. + if (this._panel.state == "closed" && this.openViews.length) { + this.dispatchCustomEvent("popuphidden"); + return false; + } + + if ( + options && + typeof options == "object" && + options.triggerEvent && + (options.triggerEvent.type == "keypress" || + options.triggerEvent.type == "keydown" || + options.triggerEvent?.inputSource == + MouseEvent.MOZ_SOURCE_KEYBOARD) && + this.openViews.length + ) { + // This was opened via the keyboard, so focus the first item. + this.openViews[0].focusWhenActive = true; + } + + return true; + } catch (ex) { + this.dispatchCustomEvent("popuphidden"); + throw ex; + } + })); + } + + /** + * Closes the panel associated with this PanelMultiView. + * + * If the openPopup method was called but the panel has not been displayed + * yet, the operation is canceled and the panel will not be displayed, but the + * "popuphidden" event is fired synchronously anyways. + * + * This means that by the time this method returns all the operations handled + * by the "popuphidden" event are completed, for example resetting the "open" + * state of the anchor, and the panel is already invisible. + * + * @note The value of animate could be changed to true by default, in both + * this and the static method above. (see bug 1769813) + * + * @param {Boolean} [animate] Whether to show a fade animation. Optional. + * + */ + hidePopup(animate = false) { + if (!this.node || !this.connected) { + return; + } + + // If we have already reached the _panel.openPopup call in the openPopup + // method, we can call hidePopup. Otherwise, we have to cancel the latest + // request to open the panel, which will have no effect if the request has + // been canceled already. + if (["open", "showing"].includes(this._panel.state)) { + this._panel.hidePopup(animate); + } else { + this._openPopupCancelCallback?.(); + } + + // We close all the views synchronously, so that they are ready to be opened + // in other PanelMultiView instances. The "popuphidden" handler may also + // call this function, but the second time openViews will be empty. + this.closeAllViews(); + } + + /** + * Move any child subviews into the element defined by "viewCacheId" to make + * sure they will not be removed together with the <panelmultiview> element. + */ + _moveOutKids() { + // this.node may have been set to null by a call to disconnect(). + let viewCacheId = this.node?.getAttribute("viewCacheId"); + if (!viewCacheId) { + return; + } + + // Node.children and Node.children is live to DOM changes like the + // ones we're about to do, so iterate over a static copy: + let subviews = Array.from(this._viewStack.children); + let viewCache = this.document.getElementById("appMenu-viewCache"); + for (let subview of subviews) { + viewCache.appendChild(subview); + } + } + + /** + * Slides in the specified view as a subview. + * + * @param viewIdOrNode + * DOM element or string ID of the <panelview> to display. + * @param anchor + * DOM element that triggered the subview, which will be highlighted + * and whose "label" attribute will be used for the title of the + * subview when a "title" attribute is not specified. + */ + showSubView(viewIdOrNode, anchor) { + this._showSubView(viewIdOrNode, anchor).catch(console.error); + } + async _showSubView(viewIdOrNode, anchor) { + let viewNode = + typeof viewIdOrNode == "string" + ? PanelMultiView.getViewNode(this.document, viewIdOrNode) + : viewIdOrNode; + if (!viewNode) { + console.error(new Error(`Subview ${viewIdOrNode} doesn't exist.`)); + return; + } + + if (!this.openViews.length) { + console.error(new Error(`Cannot show a subview in a closed panel.`)); + return; + } + + let prevPanelView = this.openViews[this.openViews.length - 1]; + let nextPanelView = PanelView.forNode(viewNode); + if (this.openViews.includes(nextPanelView)) { + console.error(new Error(`Subview ${viewNode.id} is already open.`)); + return; + } + + // Do not re-enter the process if navigation is already in progress. Since + // there is only one active view at any given time, we can do this check + // safely, even considering that during the navigation process the actual + // view to which prevPanelView refers will change. + if (!prevPanelView.active) { + return; + } + // If prevPanelView._doingKeyboardActivation is true, it will be reset to + // false synchronously. Therefore, we must capture it before we use any + // "await" statements. + let doingKeyboardActivation = prevPanelView._doingKeyboardActivation; + // Marking the view that is about to scrolled out of the visible area as + // inactive will prevent re-entrancy and also disable keyboard navigation. + // From this point onwards, "await" statements can be used safely. + prevPanelView.active = false; + + // Provide visual feedback while navigation is in progress, starting before + // the transition starts and ending when the previous view is invisible. + anchor?.setAttribute("open", "true"); + try { + // If the ViewShowing event cancels the operation we have to re-enable + // keyboard navigation, but this must be avoided if the panel was closed. + if (!(await this._openView(nextPanelView))) { + if (prevPanelView.isOpenIn(this)) { + // We don't raise a ViewShown event because nothing actually changed. + // Technically we should use a different state flag just because there + // is code that could check the "active" property to determine whether + // to wait for a ViewShown event later, but this only happens in + // regression tests and is less likely to be a technique used in + // production code, where use of ViewShown is less common. + prevPanelView.active = true; + } + return; + } + + prevPanelView.captureKnownSize(); + + // The main view of a panel can be a subview in another one. Make sure to + // reset all the properties that may be set on a subview. + nextPanelView.mainview = false; + // The header may be set by a Fluent message with a title attribute + // that has changed immediately before showing the panelview, + // and so is not reflected in the DOM yet. + let title; + const l10nId = viewNode.getAttribute("data-l10n-id"); + if (l10nId) { + const l10nArgs = viewNode.getAttribute("data-l10n-args"); + const args = l10nArgs ? JSON.parse(l10nArgs) : undefined; + const [msg] = await viewNode.ownerDocument.l10n.formatMessages([ + { id: l10nId, args }, + ]); + title = msg.attributes.find(a => a.name === "title")?.value; + } + // If not set by Fluent, the header may change based on how the subview was opened. + title ??= viewNode.getAttribute("title") || anchor?.getAttribute("label"); + nextPanelView.headerText = title; + // The constrained width of subviews may also vary between panels. + nextPanelView.minMaxWidth = prevPanelView.knownWidth; + let lockPanelVertical = + this.openViews[0].node.getAttribute("lockpanelvertical") == "true"; + nextPanelView.minMaxHeight = lockPanelVertical + ? prevPanelView.knownHeight + : 0; + + if (anchor) { + viewNode.classList.add("PanelUI-subView"); + } + + await this._transitionViews(prevPanelView.node, viewNode, false); + } finally { + anchor?.removeAttribute("open"); + } + + nextPanelView.focusWhenActive = doingKeyboardActivation; + this._activateView(nextPanelView); + } + + /** + * Navigates backwards by sliding out the most recent subview. + */ + goBack() { + this._goBack().catch(console.error); + } + async _goBack() { + if (this.openViews.length < 2) { + // This may be called by keyboard navigation or external code when only + // the main view is open. + return; + } + + let prevPanelView = this.openViews[this.openViews.length - 1]; + let nextPanelView = this.openViews[this.openViews.length - 2]; + + // Like in the showSubView method, do not re-enter navigation while it is + // in progress, and make the view inactive immediately. From this point + // onwards, "await" statements can be used safely. + if (!prevPanelView.active) { + return; + } + prevPanelView.active = false; + + prevPanelView.captureKnownSize(); + await this._transitionViews(prevPanelView.node, nextPanelView.node, true); + + this._closeLatestView(); + + this._activateView(nextPanelView); + } + + /** + * Prepares the main view before showing the panel. + */ + async _showMainView() { + let nextPanelView = PanelView.forNode( + PanelMultiView.getViewNode( + this.document, + this.node.getAttribute("mainViewId") + ) + ); + + // If the view is already open in another panel, close the panel first. + let oldPanelMultiViewNode = nextPanelView.node.panelMultiView; + if (oldPanelMultiViewNode) { + PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup(); + // Wait for a layout flush after hiding the popup, otherwise the view may + // not be displayed correctly for some time after the new panel is opened. + // This is filed as bug 1441015. + await this.window.promiseDocumentFlushed(() => {}); + } + + if (!(await this._openView(nextPanelView))) { + return false; + } + + // The main view of a panel can be a subview in another one. Make sure to + // reset all the properties that may be set on a subview. + nextPanelView.mainview = true; + nextPanelView.headerText = ""; + nextPanelView.minMaxWidth = 0; + nextPanelView.minMaxHeight = 0; + + // Ensure the view will be visible once the panel is opened. + nextPanelView.visible = true; + + return true; + } + + /** + * Opens the specified PanelView and dispatches the ViewShowing event, which + * can be used to populate the subview or cancel the operation. + * + * This also clears all the attributes and styles that may be left by a + * transition that was interrupted. + * + * @resolves With true if the view was opened, false otherwise. + */ + async _openView(panelView) { + if (panelView.node.parentNode != this._viewStack) { + this._viewStack.appendChild(panelView.node); + } + + panelView.node.panelMultiView = this.node; + this.openViews.push(panelView); + + // Panels could contain out-pf-process <browser> elements, that need to be + // supported with a remote attribute on the panel in order to display properly. + // See bug https://bugzilla.mozilla.org/show_bug.cgi?id=1365660 + if (panelView.node.getAttribute("remote") == "true") { + this._panel.setAttribute("remote", "true"); + } + + let canceled = await panelView.dispatchAsyncEvent("ViewShowing"); + + // The panel can be hidden while we are processing the ViewShowing event. + // This results in all the views being closed synchronously, and at this + // point the ViewHiding event has already been dispatched for all of them. + if (!this.openViews.length) { + return false; + } + + // Check if the event requested cancellation but the panel is still open. + if (canceled) { + // Handlers for ViewShowing can't know if a different handler requested + // cancellation, so this will dispatch a ViewHiding event to give a chance + // to clean up. + this._closeLatestView(); + return false; + } + + // Clean up all the attributes and styles related to transitions. We do this + // here rather than when the view is closed because we are likely to make + // other DOM modifications soon, which isn't the case when closing. + let { style } = panelView.node; + style.removeProperty("outline"); + style.removeProperty("width"); + + return true; + } + + /** + * Activates the specified view and raises the ViewShown event, unless the + * view was closed in the meantime. + */ + _activateView(panelView) { + if (panelView.isOpenIn(this)) { + panelView.active = true; + if (panelView.focusWhenActive) { + panelView.focusFirstNavigableElement(false, true); + panelView.focusWhenActive = false; + } + panelView.dispatchCustomEvent("ViewShown"); + } + } + + /** + * Closes the most recent PanelView and raises the ViewHiding event. + * + * @note The ViewHiding event is not cancelable and should probably be renamed + * to ViewHidden or ViewClosed instead, see bug 1438507. + */ + _closeLatestView() { + let panelView = this.openViews.pop(); + panelView.clearNavigation(); + panelView.dispatchCustomEvent("ViewHiding"); + panelView.node.panelMultiView = null; + // Views become invisible synchronously when they are closed, and they won't + // become visible again until they are opened. When this is called at the + // end of backwards navigation, the view is already invisible. + panelView.visible = false; + } + + /** + * Closes all the views that are currently open. + */ + closeAllViews() { + // Raise ViewHiding events for open views in reverse order. + while (this.openViews.length) { + this._closeLatestView(); + } + } + + /** + * Apply a transition to 'slide' from the currently active view to the next + * one. + * Sliding the next subview in means that the previous panelview stays where it + * is and the active panelview slides in from the left in LTR mode, right in + * RTL mode. + * + * @param {panelview} previousViewNode Node that is currently displayed, but + * is about to be transitioned away. This + * must be already inactive at this point. + * @param {panelview} viewNode Node that will becode the active view, + * after the transition has finished. + * @param {Boolean} reverse Whether we're navigation back to a + * previous view or forward to a next view. + */ + async _transitionViews(previousViewNode, viewNode, reverse) { + const { window } = this; + + let nextPanelView = PanelView.forNode(viewNode); + let prevPanelView = PanelView.forNode(previousViewNode); + + let details = (this._transitionDetails = { + phase: TRANSITION_PHASES.START, + }); + + // Set the viewContainer dimensions to make sure only the current view is + // visible. + let olderView = reverse ? nextPanelView : prevPanelView; + this._viewContainer.style.minHeight = olderView.knownHeight + "px"; + this._viewContainer.style.height = prevPanelView.knownHeight + "px"; + this._viewContainer.style.width = prevPanelView.knownWidth + "px"; + // Lock the dimensions of the window that hosts the popup panel. + let rect = this._getBoundsWithoutFlushing(this._panel); + this._panel.style.width = rect.width + "px"; + this._panel.style.height = rect.height + "px"; + + let viewRect; + if (reverse) { + // Use the cached size when going back to a previous view, but not when + // reopening a subview, because its contents may have changed. + viewRect = { + width: nextPanelView.knownWidth, + height: nextPanelView.knownHeight, + }; + nextPanelView.visible = true; + } else if (viewNode.customRectGetter) { + // We use a customRectGetter for WebExtensions panels, because they need + // to query the size from an embedded browser. The presence of this + // getter also provides an indication that the view node shouldn't be + // moved around, otherwise the state of the browser would get disrupted. + let width = prevPanelView.knownWidth; + let height = prevPanelView.knownHeight; + viewRect = Object.assign({ height, width }, viewNode.customRectGetter()); + nextPanelView.visible = true; + // Until the header is visible, it has 0 height. + // Wait for layout before measuring it + let header = viewNode.firstElementChild; + if (header && header.classList.contains("panel-header")) { + viewRect.height += await window.promiseDocumentFlushed(() => { + return this._getBoundsWithoutFlushing(header).height; + }); + } + // Bail out if the panel was closed in the meantime. + if (!nextPanelView.isOpenIn(this)) { + return; + } + } else { + this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px"; + this._offscreenViewStack.appendChild(viewNode); + nextPanelView.visible = true; + + viewRect = await window.promiseDocumentFlushed(() => { + return this._getBoundsWithoutFlushing(viewNode); + }); + // Bail out if the panel was closed in the meantime. + if (!nextPanelView.isOpenIn(this)) { + return; + } + + // Place back the view after all the other views that are already open in + // order for the transition to work as expected. + this._viewStack.appendChild(viewNode); + + this._offscreenViewStack.style.removeProperty("min-height"); + } + + this._transitioning = true; + details.phase = TRANSITION_PHASES.PREPARE; + + // The 'magic' part: build up the amount of pixels to move right or left. + let moveToLeft = + (this.window.RTL_UI && !reverse) || (!this.window.RTL_UI && reverse); + let deltaX = prevPanelView.knownWidth; + let deepestNode = reverse ? previousViewNode : viewNode; + + // With a transition when navigating backwards - user hits the 'back' + // button - we need to make sure that the views are positioned in a way + // that a translateX() unveils the previous view from the right direction. + if (reverse) { + this._viewStack.style.marginInlineStart = "-" + deltaX + "px"; + } + + // Set the transition style and listen for its end to clean up and make sure + // the box sizing becomes dynamic again. + // Somehow, putting these properties in PanelUI.css doesn't work for newly + // shown nodes in a XUL parent node. + this._viewStack.style.transition = + "transform var(--animation-easing-function)" + + " var(--panelui-subview-transition-duration)"; + this._viewStack.style.willChange = "transform"; + // Use an outline instead of a border so that the size is not affected. + deepestNode.style.outline = "1px solid var(--panel-separator-color)"; + + // Now that all the elements are in place for the start of the transition, + // give the layout code a chance to set the initial values. + await window.promiseDocumentFlushed(() => {}); + // Bail out if the panel was closed in the meantime. + if (!nextPanelView.isOpenIn(this)) { + return; + } + + // Now set the viewContainer dimensions to that of the new view, which + // kicks of the height animation. + this._viewContainer.style.height = viewRect.height + "px"; + this._viewContainer.style.width = viewRect.width + "px"; + this._panel.style.removeProperty("width"); + this._panel.style.removeProperty("height"); + // We're setting the width property to prevent flickering during the + // sliding animation with smaller views. + viewNode.style.width = viewRect.width + "px"; + + // Kick off the transition! + details.phase = TRANSITION_PHASES.TRANSITION; + + // If we're going to show the main view, we can remove the + // min-height property on the view container. It's also time + // to set the mainviewshowing attribute on the popup. + if (viewNode.getAttribute("mainview")) { + this._viewContainer.style.removeProperty("min-height"); + this._panel.setAttribute("mainviewshowing", true); + } else { + this._panel.removeAttribute("mainviewshowing"); + } + + // Avoid transforming element if the user has prefers-reduced-motion set + if ( + this.window.matchMedia("(prefers-reduced-motion: no-preference)").matches + ) { + this._viewStack.style.transform = + "translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)"; + + await new Promise(resolve => { + details.resolve = resolve; + this._viewContainer.addEventListener( + "transitionend", + (details.listener = ev => { + // It's quite common that `height` on the view container doesn't need + // to transition, so we make sure to do all the work on the transform + // transition-end, because that is guaranteed to happen. + if ( + ev.target != this._viewStack || + ev.propertyName != "transform" + ) { + return; + } + this._viewContainer.removeEventListener( + "transitionend", + details.listener + ); + delete details.listener; + resolve(); + }) + ); + this._viewContainer.addEventListener( + "transitioncancel", + (details.cancelListener = ev => { + if (ev.target != this._viewStack) { + return; + } + this._viewContainer.removeEventListener( + "transitioncancel", + details.cancelListener + ); + delete details.cancelListener; + resolve(); + }) + ); + }); + } + + // Bail out if the panel was closed during the transition. + if (!nextPanelView.isOpenIn(this)) { + return; + } + prevPanelView.visible = false; + + // This will complete the operation by removing any transition properties. + nextPanelView.node.style.removeProperty("width"); + deepestNode.style.removeProperty("outline"); + this._cleanupTransitionPhase(); + // Ensure the newly-visible view has been through a layout flush before we + // attempt to focus anything in it. + // See https://firefox-source-docs.mozilla.org/performance/bestpractices.html#detecting-and-avoiding-synchronous-reflow + // for more information. + await this.window.promiseDocumentFlushed(() => {}); + nextPanelView.focusSelectedElement(); + } + + /** + * Attempt to clean up the attributes and properties set by `_transitionViews` + * above. Which attributes and properties depends on the phase the transition + * was left from. + */ + _cleanupTransitionPhase() { + if (!this._transitionDetails) { + return; + } + + let { phase, resolve, listener, cancelListener } = this._transitionDetails; + this._transitionDetails = null; + + if (phase >= TRANSITION_PHASES.START) { + this._panel.removeAttribute("width"); + this._panel.removeAttribute("height"); + this._viewContainer.style.removeProperty("height"); + this._viewContainer.style.removeProperty("width"); + } + if (phase >= TRANSITION_PHASES.PREPARE) { + this._transitioning = false; + this._viewStack.style.removeProperty("margin-inline-start"); + this._viewStack.style.removeProperty("transition"); + } + if (phase >= TRANSITION_PHASES.TRANSITION) { + this._viewStack.style.removeProperty("transform"); + if (listener) { + this._viewContainer.removeEventListener("transitionend", listener); + } + if (cancelListener) { + this._viewContainer.removeEventListener( + "transitioncancel", + cancelListener + ); + } + if (resolve) { + resolve(); + } + } + } + + _calculateMaxHeight(aEvent) { + // While opening the panel, we have to limit the maximum height of any + // view based on the space that will be available. We cannot just use + // window.screen.availTop and availHeight because these may return an + // incorrect value when the window spans multiple screens. + let anchor = this._panel.anchorNode; + let anchorRect = anchor.getBoundingClientRect(); + let screen = anchor.screen; + + // GetAvailRect returns screen-device pixels, which we can convert to CSS + // pixels here. + let availTop = {}, + availHeight = {}; + screen.GetAvailRect({}, availTop, {}, availHeight); + let cssAvailTop = availTop.value / screen.defaultCSSScaleFactor; + + // The distance from the anchor to the available margin of the screen is + // based on whether the panel will open towards the top or the bottom. + let maxHeight; + if (aEvent.alignmentPosition.startsWith("before_")) { + maxHeight = anchor.screenY - cssAvailTop; + } else { + let anchorScreenBottom = anchor.screenY + anchorRect.height; + let cssAvailHeight = availHeight.value / screen.defaultCSSScaleFactor; + maxHeight = cssAvailTop + cssAvailHeight - anchorScreenBottom; + } + + // To go from the maximum height of the panel to the maximum height of + // the view stack, we need to subtract the height of the arrow and the + // height of the opposite margin, but we cannot get their actual values + // because the panel is not visible yet. However, we know that this is + // currently 11px on Mac, 13px on Windows, and 13px on Linux. We also + // want an extra margin, both for visual reasons and to prevent glitches + // due to small rounding errors. So, we just use a value that makes + // sense for all platforms. If the arrow visuals change significantly, + // this value will be easy to adjust. + const EXTRA_MARGIN_PX = 20; + maxHeight -= EXTRA_MARGIN_PX; + return maxHeight; + } + + handleEvent(aEvent) { + // Only process actual popup events from the panel or events we generate + // ourselves, but not from menus being shown from within the panel. + if ( + aEvent.type.startsWith("popup") && + aEvent.target != this._panel && + aEvent.target != this.node + ) { + return; + } + switch (aEvent.type) { + case "keydown": + // Since we start listening for the "keydown" event when the popup is + // already showing and stop listening when the panel is hidden, we + // always have at least one view open. + let currentView = this.openViews[this.openViews.length - 1]; + currentView.keyNavigation(aEvent); + break; + case "mousemove": + this.openViews.forEach(panelView => { + if (!panelView.ignoreMouseMove) { + panelView.clearNavigation(); + } + }); + break; + case "popupshowing": { + this._viewContainer.setAttribute("panelopen", "true"); + if (!this.node.hasAttribute("disablekeynav")) { + // We add the keydown handler on the root so that it handles key + // presses when a panel appears but doesn't get focus, as happens + // when a button to open a panel is clicked with the mouse. + // However, this means the listener is on an ancestor of the panel, + // which means that handlers such as ToolbarKeyboardNavigator are + // deeper in the tree. Therefore, this must be a capturing listener + // so we get the event first. + this.document.documentElement.addEventListener("keydown", this, true); + this._panel.addEventListener("mousemove", this); + } + break; + } + case "popuppositioned": { + if (this._panel.state == "showing") { + let maxHeight = this._calculateMaxHeight(aEvent); + this._viewStack.style.maxHeight = maxHeight + "px"; + this._offscreenViewStack.style.maxHeight = maxHeight + "px"; + } + break; + } + case "popupshown": + // The main view is always open and visible when the panel is first + // shown, so we can check the height of the description elements it + // contains and notify consumers using the ViewShown event. In order to + // minimize flicker we need to allow synchronous reflows, and we still + // make sure the ViewShown event is dispatched synchronously. + let mainPanelView = this.openViews[0]; + this._activateView(mainPanelView); + break; + case "popuphidden": { + // WebExtensions consumers can hide the popup from viewshowing, or + // mid-transition, which disrupts our state: + this._transitioning = false; + this._viewContainer.removeAttribute("panelopen"); + this._cleanupTransitionPhase(); + this.document.documentElement.removeEventListener( + "keydown", + this, + true + ); + this._panel.removeEventListener("mousemove", this); + this.closeAllViews(); + + // Clear the main view size caches. The dimensions could be different + // when the popup is opened again, e.g. through touch mode sizing. + this._viewContainer.style.removeProperty("min-height"); + this._viewStack.style.removeProperty("max-height"); + this._viewContainer.style.removeProperty("width"); + this._viewContainer.style.removeProperty("height"); + + this.dispatchCustomEvent("PanelMultiViewHidden"); + break; + } + } + } +}; + +/** + * This is associated to <panelview> elements. + */ +export var PanelView = class extends AssociatedToNode { + constructor(node) { + super(node); + + /** + * Indicates whether the view is active. When this is false, consumers can + * wait for the ViewShown event to know when the view becomes active. + */ + this.active = false; + + /** + * Specifies whether the view should be focused when active. When this + * is true, the first navigable element in the view will be focused + * when the view becomes active. This should be set to true when the view + * is activated from the keyboard. It will be set to false once the view + * is active. + */ + this.focusWhenActive = false; + } + + /** + * Indicates whether the view is open in the specified PanelMultiView object. + */ + isOpenIn(panelMultiView) { + return this.node.panelMultiView == panelMultiView.node; + } + + /** + * The "mainview" attribute is set before the panel is opened when this view + * is displayed as the main view, and is removed before the <panelview> is + * displayed as a subview. The same view element can be displayed as a main + * view and as a subview at different times. + */ + set mainview(value) { + if (value) { + this.node.setAttribute("mainview", true); + } else { + this.node.removeAttribute("mainview"); + } + } + + /** + * Determines whether the view is visible. Setting this to false also resets + * the "active" property. + */ + set visible(value) { + if (value) { + this.node.setAttribute("visible", true); + } else { + this.node.removeAttribute("visible"); + this.active = false; + this.focusWhenActive = false; + } + } + + /** + * Constrains the width of this view using the "min-width" and "max-width" + * styles. Setting this to zero removes the constraints. + */ + set minMaxWidth(value) { + let style = this.node.style; + if (value) { + style.minWidth = style.maxWidth = value + "px"; + } else { + style.removeProperty("min-width"); + style.removeProperty("max-width"); + } + } + + /** + * Constrains the height of this view using the "min-height" and "max-height" + * styles. Setting this to zero removes the constraints. + */ + set minMaxHeight(value) { + let style = this.node.style; + if (value) { + style.minHeight = style.maxHeight = value + "px"; + } else { + style.removeProperty("min-height"); + style.removeProperty("max-height"); + } + } + + /** + * Adds a header with the given title, or removes it if the title is empty. + */ + set headerText(value) { + let ensureHeaderSeparator = headerNode => { + if (headerNode.nextSibling.tagName != "toolbarseparator") { + let separator = this.document.createXULElement("toolbarseparator"); + this.node.insertBefore(separator, headerNode.nextSibling); + } + }; + + // If the header already exists, update or remove it as requested. + let isMainView = this.node.getAttribute("mainview"); + let header = this.node.querySelector(".panel-header"); + if (header) { + let headerBackButton = header.querySelector(".subviewbutton-back"); + if (isMainView) { + if (headerBackButton) { + // A back button should not appear in a mainview. + // This codepath can be reached if a user enters a panelview in + // the overflow panel (like the Profiler), and then unpins it back to the toolbar. + headerBackButton.remove(); + } + } + if (value) { + if ( + !isMainView && + !headerBackButton && + !this.node.getAttribute("no-back-button") + ) { + // Add a back button when not in mainview (if it doesn't exist already), + // also when a panelview specifies it doesn't want a back button, + // like the Report Broken Site (sent) panelview. + header.prepend(this.createHeaderBackButton()); + } + // Set the header title based on the value given. + header.querySelector(".panel-header > h1 > span").textContent = value; + ensureHeaderSeparator(header); + } else if ( + !this.node.getAttribute("has-custom-header") && + !this.node.getAttribute("mainview-with-header") + ) { + // No value supplied, and the panelview doesn't have a certain requirement + // for any kind of header, so remove it and the following toolbarseparator. + if (header.nextSibling.tagName == "toolbarseparator") { + header.nextSibling.remove(); + } + header.remove(); + return; + } + // Either the header exists and has been adjusted accordingly by now, + // or it doesn't (or shouldn't) exist. Bail out to not create a duplicate header. + return; + } + + // The header doesn't and shouldn't exist, only create it if needed. + if (!value) { + return; + } + + header = this.document.createXULElement("box"); + header.classList.add("panel-header"); + + if (!isMainView) { + let backButton = this.createHeaderBackButton(); + header.append(backButton); + } + + let h1 = this.document.createElement("h1"); + let span = this.document.createElement("span"); + span.textContent = value; + h1.appendChild(span); + + header.append(h1); + this.node.prepend(header); + + ensureHeaderSeparator(header); + } + + /** + * Creates and returns a panel header back toolbarbutton. + */ + createHeaderBackButton() { + let backButton = this.document.createXULElement("toolbarbutton"); + backButton.className = + "subviewbutton subviewbutton-iconic subviewbutton-back"; + backButton.setAttribute("closemenu", "none"); + backButton.setAttribute("tabindex", "0"); + backButton.setAttribute( + "aria-label", + lazy.gBundle.GetStringFromName("panel.back") + ); + backButton.addEventListener("command", () => { + // The panelmultiview element may change if the view is reused. + this.node.panelMultiView.goBack(); + backButton.blur(); + }); + return backButton; + } + + /** + * Also make sure that the correct method is called on CustomizableWidget. + */ + dispatchCustomEvent(...args) { + lazy.CustomizableUI.ensureSubviewListeners(this.node); + return super.dispatchCustomEvent(...args); + } + + /** + * Populates the "knownWidth" and "knownHeight" properties with the current + * dimensions of the view. These may be zero if the view is invisible. + * + * These values are relevant during transitions and are retained for backwards + * navigation if the view is still open but is invisible. + */ + captureKnownSize() { + let rect = this._getBoundsWithoutFlushing(this.node); + this.knownWidth = rect.width; + this.knownHeight = rect.height; + } + + /** + * Determine whether an element can only be navigated to with tab/shift+tab, + * not the arrow keys. + */ + _isNavigableWithTabOnly(element) { + let tag = element.localName; + return ( + tag == "menulist" || + tag == "select" || + tag == "radiogroup" || + tag == "input" || + tag == "textarea" || + // Allow tab to reach embedded documents. + tag == "browser" || + tag == "iframe" || + // This is currently needed for the unified extensions panel to allow + // users to use up/down arrow to more quickly move between the extension + // items. See Bug 1784118 + element.dataset?.navigableWithTabOnly === "true" + ); + } + + /** + * Make a TreeWalker for keyboard navigation. + * + * @param {Boolean} arrowKey If `true`, elements only navigable with tab are + * excluded. + */ + _makeNavigableTreeWalker(arrowKey) { + let filter = node => { + if (node.disabled) { + return NodeFilter.FILTER_REJECT; + } + let bounds = this._getBoundsWithoutFlushing(node); + if (bounds.width == 0 || bounds.height == 0) { + return NodeFilter.FILTER_REJECT; + } + let isNavigableWithTabOnly = this._isNavigableWithTabOnly(node); + // Early return when the node is navigable with tab only and we are using + // arrow keys so that nodes like button, toolbarbutton, checkbox, etc. + // can also be marked as "navigable with tab only", otherwise the next + // condition will unconditionally make them focusable. + if (arrowKey && isNavigableWithTabOnly) { + return NodeFilter.FILTER_REJECT; + } + let localName = node.localName.toLowerCase(); + if ( + localName == "button" || + localName == "toolbarbutton" || + localName == "checkbox" || + localName == "a" || + localName == "moz-toggle" || + node.classList.contains("text-link") || + (!arrowKey && isNavigableWithTabOnly) + ) { + // Set the tabindex attribute to make sure the node is focusable. + // Don't do this for browser and iframe elements because this breaks + // tabbing behavior. They're already focusable anyway. + if ( + localName != "browser" && + localName != "iframe" && + !node.hasAttribute("tabindex") + ) { + node.setAttribute("tabindex", "-1"); + } + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + return this.document.createTreeWalker( + this.node, + NodeFilter.SHOW_ELEMENT, + filter + ); + } + + /** + * Get a TreeWalker which finds elements navigable with tab/shift+tab. + */ + get _tabNavigableWalker() { + if (!this.__tabNavigableWalker) { + this.__tabNavigableWalker = this._makeNavigableTreeWalker(false); + } + return this.__tabNavigableWalker; + } + + /** + * Get a TreeWalker which finds elements navigable with up/down arrow keys. + */ + get _arrowNavigableWalker() { + if (!this.__arrowNavigableWalker) { + this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true); + } + return this.__arrowNavigableWalker; + } + + /** + * Element that is currently selected with the keyboard, or null if no element + * is selected. Since the reference is held weakly, it can become null or + * undefined at any time. + */ + get selectedElement() { + return this._selectedElement && this._selectedElement.get(); + } + set selectedElement(value) { + if (!value) { + delete this._selectedElement; + } else { + this._selectedElement = Cu.getWeakReference(value); + } + } + + /** + * Focuses and moves keyboard selection to the first navigable element. + * This is a no-op if there are no navigable elements. + * + * @param {Boolean} homeKey `true` if this is for the home key. + * @param {Boolean} skipBack `true` if the Back button should be skipped. + */ + focusFirstNavigableElement(homeKey = false, skipBack = false) { + // The home key is conceptually similar to the up/down arrow keys. + let walker = homeKey + ? this._arrowNavigableWalker + : this._tabNavigableWalker; + walker.currentNode = walker.root; + this.selectedElement = walker.firstChild(); + if ( + skipBack && + walker.currentNode && + walker.currentNode.classList.contains("subviewbutton-back") && + walker.nextNode() + ) { + this.selectedElement = walker.currentNode; + } + this.focusSelectedElement(/* byKey */ true); + } + + /** + * Focuses and moves keyboard selection to the last navigable element. + * This is a no-op if there are no navigable elements. + * + * @param {Boolean} endKey `true` if this is for the end key. + */ + focusLastNavigableElement(endKey = false) { + // The end key is conceptually similar to the up/down arrow keys. + let walker = endKey ? this._arrowNavigableWalker : this._tabNavigableWalker; + walker.currentNode = walker.root; + this.selectedElement = walker.lastChild(); + this.focusSelectedElement(/* byKey */ true); + } + + /** + * Based on going up or down, select the previous or next focusable element. + * + * @param {Boolean} isDown whether we're going down (true) or up (false). + * @param {Boolean} arrowKey `true` if this is for the up/down arrow keys. + * + * @return {DOMNode} the element we selected. + */ + moveSelection(isDown, arrowKey = false) { + let walker = arrowKey + ? this._arrowNavigableWalker + : this._tabNavigableWalker; + let oldSel = this.selectedElement; + let newSel; + if (oldSel) { + walker.currentNode = oldSel; + newSel = isDown ? walker.nextNode() : walker.previousNode(); + } + // If we couldn't find something, select the first or last item: + if (!newSel) { + walker.currentNode = walker.root; + newSel = isDown ? walker.firstChild() : walker.lastChild(); + } + this.selectedElement = newSel; + return newSel; + } + + /** + * Allow for navigating subview buttons using the arrow keys and the Enter key. + * The Up and Down keys can be used to navigate the list up and down and the + * Enter, Right or Left - depending on the text direction - key can be used to + * simulate a click on the currently selected button. + * The Right or Left key - depending on the text direction - can be used to + * navigate to the previous view, functioning as a shortcut for the view's + * back button. + * Thus, in LTR mode: + * - The Right key functions the same as the Enter key, simulating a click + * - The Left key triggers a navigation back to the previous view. + * + * Key navigation is only enabled while the view is active, meaning that this + * method will return early if it is invoked during a sliding transition. + * + * @param {KeyEvent} event + */ + keyNavigation(event) { + if (!this.active) { + return; + } + + let focus = this.document.activeElement; + // Make sure the focus is actually inside the panel. (It might not be if + // the panel was opened with the mouse.) If it isn't, we don't care + // about it for our purposes. + // We use Node.compareDocumentPosition because Node.contains doesn't + // behave as expected for anonymous content; e.g. the input inside a + // textbox. + if ( + focus && + !( + this.node.compareDocumentPosition(focus) & + Node.DOCUMENT_POSITION_CONTAINED_BY + ) + ) { + focus = null; + } + + // Some panels contain embedded documents. We can't manage + // keyboard navigation within those. + if (focus && (focus.tagName == "browser" || focus.tagName == "iframe")) { + return; + } + + let stop = () => { + event.stopPropagation(); + event.preventDefault(); + }; + + // If the focused element is only navigable with tab, it wants the arrow + // keys, etc. We shouldn't handle any keys except tab and shift+tab. + // We make a function for this for performance reasons: we only want to + // check this for keys we potentially care about, not *all* keys. + let tabOnly = () => { + // We use the real focus rather than this.selectedElement because focus + // might have been moved without keyboard navigation (e.g. mouse click) + // and this.selectedElement is only updated for keyboard navigation. + return focus && this._isNavigableWithTabOnly(focus); + }; + + // If a context menu is open, we must let it handle all keys. + // Normally, this just happens, but because we have a capturing root + // element keydown listener, our listener takes precedence. + // Again, we only want to do this check on demand for performance. + let isContextMenuOpen = () => { + if (!focus) { + return false; + } + let contextNode = focus.closest("[context]"); + if (!contextNode) { + return false; + } + let context = contextNode.getAttribute("context"); + if (!context) { + return false; + } + let popup = this.document.getElementById(context); + return popup && popup.state == "open"; + }; + + this.ignoreMouseMove = false; + + let keyCode = event.code; + switch (keyCode) { + case "ArrowDown": + case "ArrowUp": + if (tabOnly()) { + break; + } + // Fall-through... + case "Tab": { + if ( + isContextMenuOpen() || + // Tab in an open menulist should close it. + (focus && focus.localName == "menulist" && focus.open) + ) { + break; + } + stop(); + let isDown = + keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey); + let button = this.moveSelection(isDown, keyCode != "Tab"); + Services.focus.setFocus(button, Services.focus.FLAG_BYKEY); + break; + } + case "Home": + if (tabOnly() || isContextMenuOpen()) { + break; + } + stop(); + this.focusFirstNavigableElement(true); + break; + case "End": + if (tabOnly() || isContextMenuOpen()) { + break; + } + stop(); + this.focusLastNavigableElement(true); + break; + case "ArrowLeft": + case "ArrowRight": { + if (tabOnly() || isContextMenuOpen()) { + break; + } + stop(); + if ( + (!this.window.RTL_UI && keyCode == "ArrowLeft") || + (this.window.RTL_UI && keyCode == "ArrowRight") + ) { + this.node.panelMultiView.goBack(); + break; + } + // If the current button is _not_ one that points to a subview, pressing + // the arrow key shouldn't do anything. + let button = this.selectedElement; + if (!button || !button.classList.contains("subviewbutton-nav")) { + break; + } + } + // Fall-through... + case "Space": + case "NumpadEnter": + case "Enter": { + if (tabOnly() || isContextMenuOpen()) { + break; + } + let button = this.selectedElement; + if (!button || button?.localName == "moz-toggle") { + break; + } + stop(); + + this._doingKeyboardActivation = true; + const details = { + bubbles: true, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + }; + let dispEvent = new event.target.ownerGlobal.MouseEvent( + "mousedown", + details + ); + button.dispatchEvent(dispEvent); + // This event will trigger a command event too. + dispEvent = new event.target.ownerGlobal.MouseEvent("click", details); + button.dispatchEvent(dispEvent); + this._doingKeyboardActivation = false; + break; + } + } + } + + /** + * Focus the last selected element in the view, if any. + * + * @param byKey {Boolean} whether focus was moved by the user pressing a key. + * Needed to ensure we show focus styles in the right cases. + */ + focusSelectedElement(byKey = false) { + let selected = this.selectedElement; + if (selected) { + let flag = byKey ? Services.focus.FLAG_BYKEY : 0; + Services.focus.setFocus(selected, flag); + } + } + + /** + * Clear all traces of keyboard navigation happening right now. + */ + clearNavigation() { + let selected = this.selectedElement; + if (selected) { + selected.blur(); + this.selectedElement = null; + } + } +}; diff --git a/browser/components/customizableui/SearchWidgetTracker.sys.mjs b/browser/components/customizableui/SearchWidgetTracker.sys.mjs new file mode 100644 index 0000000000..92f61d5b76 --- /dev/null +++ b/browser/components/customizableui/SearchWidgetTracker.sys.mjs @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Keeps the "browser.search.widget.inNavBar" preference synchronized, + * and ensures persisted widths are updated if the search bar is removed. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { CustomizableUI } from "resource:///modules/CustomizableUI.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", +}); + +const WIDGET_ID = "search-container"; +const PREF_NAME = "browser.search.widget.inNavBar"; + +export const SearchWidgetTracker = { + init() { + this.onWidgetReset = this.onWidgetUndoMove = node => { + if (node.id == WIDGET_ID) { + this.syncPreferenceWithWidget(); + this.removePersistedWidths(); + } + }; + CustomizableUI.addListener(this); + Services.prefs.addObserver(PREF_NAME, () => + this.syncWidgetWithPreference() + ); + this._updateSearchBarVisibilityBasedOnUsage(); + }, + + onWidgetAdded(widgetId, area) { + if (widgetId == WIDGET_ID && area == CustomizableUI.AREA_NAVBAR) { + this.syncPreferenceWithWidget(); + } + }, + + onWidgetRemoved(aWidgetId, aArea) { + if (aWidgetId == WIDGET_ID && aArea == CustomizableUI.AREA_NAVBAR) { + this.syncPreferenceWithWidget(); + this.removePersistedWidths(); + } + }, + + onAreaNodeRegistered(aArea) { + // The placement of the widget always takes priority, and the preference + // should always match the actual placement when the browser starts up - i.e. + // once the navigation bar has been registered. + if (aArea == CustomizableUI.AREA_NAVBAR) { + this.syncPreferenceWithWidget(); + } + }, + + onCustomizeEnd() { + // onWidgetUndoMove does not fire when the search container is moved back to + // the customization palette as a result of an undo, so we sync again here. + this.syncPreferenceWithWidget(); + }, + + syncPreferenceWithWidget() { + Services.prefs.setBoolPref(PREF_NAME, this.widgetIsInNavBar); + }, + + syncWidgetWithPreference() { + let newValue = Services.prefs.getBoolPref(PREF_NAME); + if (newValue == this.widgetIsInNavBar) { + return; + } + + if (newValue) { + // The URL bar widget is always present in the navigation toolbar, so we + // can simply read its position to place the search bar right after it. + CustomizableUI.addWidgetToArea( + WIDGET_ID, + CustomizableUI.AREA_NAVBAR, + CustomizableUI.getPlacementOfWidget("urlbar-container").position + 1 + ); + lazy.BrowserUsageTelemetry.recordWidgetChange( + WIDGET_ID, + CustomizableUI.AREA_NAVBAR, + "searchpref" + ); + } else { + CustomizableUI.removeWidgetFromArea(WIDGET_ID); + lazy.BrowserUsageTelemetry.recordWidgetChange( + WIDGET_ID, + null, + "searchpref" + ); + } + }, + + _updateSearchBarVisibilityBasedOnUsage() { + let searchBarLastUsed = Services.prefs.getStringPref( + "browser.search.widget.lastUsed", + "" + ); + if (searchBarLastUsed) { + const removeAfterDaysUnused = Services.prefs.getIntPref( + "browser.search.widget.removeAfterDaysUnused" + ); + let saerchBarUnusedThreshold = + removeAfterDaysUnused * 24 * 60 * 60 * 1000; + if (new Date() - new Date(searchBarLastUsed) > saerchBarUnusedThreshold) { + Services.prefs.setBoolPref("browser.search.widget.inNavBar", false); + } + } + }, + + removePersistedWidths() { + Services.xulStore.removeValue( + AppConstants.BROWSER_CHROME_URL, + WIDGET_ID, + "width" + ); + for (let win of CustomizableUI.windows) { + let searchbar = + win.document.getElementById(WIDGET_ID) || + win.gNavToolbox.palette.querySelector("#" + WIDGET_ID); + searchbar.removeAttribute("width"); + searchbar.style.removeProperty("width"); + } + }, + + get widgetIsInNavBar() { + let placement = CustomizableUI.getPlacementOfWidget(WIDGET_ID); + return placement?.area == CustomizableUI.AREA_NAVBAR; + }, +}; diff --git a/browser/components/customizableui/content/.eslintrc.js b/browser/components/customizableui/content/.eslintrc.js new file mode 100644 index 0000000000..43ab18578d --- /dev/null +++ b/browser/components/customizableui/content/.eslintrc.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + env: { + "mozilla/browser-window": true, + }, + + plugins: ["mozilla"], +}; diff --git a/browser/components/customizableui/content/customizeMode.inc.xhtml b/browser/components/customizableui/content/customizeMode.inc.xhtml new file mode 100644 index 0000000000..2788cc6a8f --- /dev/null +++ b/browser/components/customizableui/content/customizeMode.inc.xhtml @@ -0,0 +1,121 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<box id="customization-content-container"> +<box id="customization-palette-container"> + <label id="customization-header" data-l10n-id="customize-mode-menu-and-toolbars-header"></label> + <vbox id="customization-palette" class="customization-palette" hidden="true"/> + <html:div id="customization-pong-arena" hidden="true"/> + <spacer id="customization-spacer"/> +</box> +<vbox id="customization-panel-container"> + <vbox id="customization-panelWrapper"> + <box class="panel-arrowbox"> + <image class="panel-arrow" side="top"/> + </box> + <box class="panel-arrowcontent" side="top"> + <vbox id="customization-panelHolder"> + <description id="customization-panelHeader" data-l10n-id="customize-mode-overflow-list-title"></description> + <description id="customization-panelDescription" data-l10n-id="customize-mode-overflow-list-description"></description> + </vbox> + <box class="panel-inner-arrowcontentfooter" hidden="true"/> + </box> + </vbox> +</vbox> +</box> +<hbox id="customization-footer"> +<checkbox id="customization-titlebar-visibility-checkbox" class="customization-checkbox" +# NB: because oncommand fires after click, by the time we've fired, the checkbox binding +# will already have switched the button's state, so this is correct: + oncommand="gCustomizeMode.toggleTitlebar(this.checked)" data-l10n-id="customize-mode-titlebar"/> +<button id="customization-toolbar-visibility-button" class="footer-button" type="menu" data-l10n-id="customize-mode-toolbars"> + <menupopup id="customization-toolbar-menu" onpopupshowing="onViewToolbarsPopupShowing(event)"/> +</button> +<button id="customization-uidensity-button" + data-l10n-id="customize-mode-uidensity" + class="footer-button" + type="menu" + hidden="true"> + <panel type="arrow" id="customization-uidensity-menu" + orient="vertical" + onpopupshowing="gCustomizeMode.onUIDensityMenuShowing();" + position="topleft bottomleft" + flip="none" + role="menu"> + <menuitem id="customization-uidensity-menuitem-compact" + class="menuitem-iconic customization-uidensity-menuitem" + role="menuitemradio" + data-l10n-id="customize-mode-uidensity-menu-compact-unsupported" + tabindex="0" + onfocus="gCustomizeMode.updateUIDensity(this.mode);" + onmouseover="gCustomizeMode.updateUIDensity(this.mode);" + onblur="gCustomizeMode.resetUIDensity();" + onmouseout="gCustomizeMode.resetUIDensity();" + oncommand="gCustomizeMode.setUIDensity(this.mode);"/> + <menuitem id="customization-uidensity-menuitem-normal" + class="menuitem-iconic customization-uidensity-menuitem" + role="menuitemradio" + data-l10n-id="customize-mode-uidensity-menu-normal" + tabindex="0" + onfocus="gCustomizeMode.updateUIDensity(this.mode);" + onmouseover="gCustomizeMode.updateUIDensity(this.mode);" + onblur="gCustomizeMode.resetUIDensity();" + onmouseout="gCustomizeMode.resetUIDensity();" + oncommand="gCustomizeMode.setUIDensity(this.mode);"/> +#ifndef XP_MACOSX + <menuitem id="customization-uidensity-menuitem-touch" + class="menuitem-iconic customization-uidensity-menuitem" + role="menuitemradio" + data-l10n-id="customize-mode-uidensity-menu-touch" + tabindex="0" + onfocus="gCustomizeMode.updateUIDensity(this.mode);" + onmouseover="gCustomizeMode.updateUIDensity(this.mode);" + onblur="gCustomizeMode.resetUIDensity();" + onmouseout="gCustomizeMode.resetUIDensity();" + oncommand="gCustomizeMode.setUIDensity(this.mode);"> + </menuitem> + <spacer hidden="true" id="customization-uidensity-touch-spacer"/> + <checkbox id="customization-uidensity-autotouchmode-checkbox" + hidden="true" + data-l10n-id="customize-mode-uidensity-auto-touch-mode-checkbox" + oncommand="gCustomizeMode.updateAutoTouchMode(this.checked)"/> +#endif + </panel> +</button> +<label is="text-link" + id="customization-lwtheme-link" + class="customization-link" + data-l10n-id="customize-mode-lwthemes-link" + onclick="gCustomizeMode.openAddonsManagerThemes();" /> + +<button id="whimsy-button" + type="checkbox" + class="footer-button" + oncommand="gCustomizeMode.togglePong(this.checked);" + hidden="true"/> + +<spacer id="customization-footer-spacer"/> +#ifdef XP_MACOSX + <button id="customization-touchbar-button" + class="footer-button" + hidden="true" + oncommand="gCustomizeMode.customizeTouchBar();" + data-l10n-id="customize-mode-touchbar-cmd"/> + <spacer hidden="true" id="customization-touchbar-spacer"/> +#endif +<button id="customization-undo-reset-button" + class="footer-button" + hidden="true" + oncommand="gCustomizeMode.undoReset();" + data-l10n-id="customize-mode-undo-cmd"/> +<button id="customization-reset-button" + oncommand="gCustomizeMode.reset();" + data-l10n-id="customize-mode-restore-defaults" + class="footer-button"/> +<button id="customization-done-button" + oncommand="gCustomizeMode.exit();" + data-l10n-id="customize-mode-done" + default="true" + class="footer-button"/> +</hbox> diff --git a/browser/components/customizableui/content/jar.mn b/browser/components/customizableui/content/jar.mn new file mode 100644 index 0000000000..08642a640c --- /dev/null +++ b/browser/components/customizableui/content/jar.mn @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/customizableui/panelUI.js diff --git a/browser/components/customizableui/content/moz.build b/browser/components/customizableui/content/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/browser/components/customizableui/content/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/browser/components/customizableui/content/panelUI.inc.xhtml b/browser/components/customizableui/content/panelUI.inc.xhtml new file mode 100644 index 0000000000..956a6ae45d --- /dev/null +++ b/browser/components/customizableui/content/panelUI.inc.xhtml @@ -0,0 +1,329 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<panel id="widget-overflow" + class="panel-no-padding" + role="group" + type="arrow" + noautofocus="true" + position="bottomright topright" + hidden="true"> + <panelmultiview mainViewId="widget-overflow-mainView"> + <panelview id="widget-overflow-mainView" + context="toolbar-context-menu"> + <vbox class="panel-subview-body"> + <vbox id="widget-overflow-list" class="widget-overflow-list" + overflowfortoolbar="nav-bar"/> + <toolbarseparator id="widget-overflow-fixed-separator" hidden="true"/> + <vbox id="widget-overflow-fixed-list" class="widget-overflow-list" hidden="true" /> + </vbox> + <toolbarseparator /> + <toolbarbutton command="cmd_CustomizeToolbars" + id="overflowMenu-customize-button" + class="subviewbutton panel-subview-footer-button" + data-l10n-id="toolbar-overflow-customize-button"/> + </panelview> + </panelmultiview> + <!-- This menu is here because not having it in the menu in which it's used flickers + when hover styles overlap. See https://bugzilla.mozilla.org/show_bug.cgi?id=1378427 . + --> + <menupopup id="customizationPanelItemContextMenu" + onpopupshowing="gCustomizeMode.onPanelContextMenuShowing(event); ToolbarContextMenu.updateExtension(this)"> + <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)" + data-lazy-l10n-id="toolbar-context-menu-manage-extension" + contexttype="toolbaritem" + class="customize-context-manageExtension"/> + <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)" + data-lazy-l10n-id="toolbar-context-menu-remove-extension" + contexttype="toolbaritem" + class="customize-context-removeExtension"/> + <menuitem oncommand="ToolbarContextMenu.reportExtensionForContextAction(this.parentElement, 'toolbar_context_menu')" + data-lazy-l10n-id="toolbar-context-menu-report-extension" + contexttype="toolbaritem" + class="customize-context-reportExtension"/> + <menuseparator/> + <menuitem oncommand="gCustomizeMode.addToPanel(this.parentNode.triggerNode, 'panelitem-context')" + id="customizationPanelItemContextMenuPin" + data-lazy-l10n-id="toolbar-context-menu-pin-to-overflow-menu" + closemenu="single" + class="customize-context-moveToPanel"/> + <menuitem oncommand="gCustomizeMode.addToToolbar(this.parentNode.triggerNode, 'panelitem-context')" + id="customizationPanelItemContextMenuUnpin" + closemenu="single" + class="customize-context-moveToToolbar" + data-l10n-id="customize-menu-unpin-from-overflowmenu"/> + <menuitem oncommand="gCustomizeMode.removeFromArea(this.parentNode.triggerNode, 'panelitem-context')" + closemenu="single" + class="customize-context-removeFromPanel" + data-lazy-l10n-id="toolbar-context-menu-remove-from-toolbar"/> + <menuseparator/> + <menuitem command="cmd_CustomizeToolbars" + class="viewCustomizeToolbar" + data-lazy-l10n-id="toolbar-context-menu-view-customize-toolbar"/> + </menupopup> +</panel> + +<html:template id="unified-extensions-panel-template"> + <panel id="unified-extensions-panel" + class="panel-no-padding" + role="group" + type="arrow" + noautofocus="true" + position="bottomright topright" + hidden="true"> + <panelmultiview mainViewId="unified-extensions-view"> + <panelview id="unified-extensions-view" + class="cui-widget-panelview" + mainview-with-header="true"> + <box class="panel-header"> + <html:h1> + <html:span data-l10n-id="unified-extensions-header-title"/> + </html:h1> + </box> + + <toolbarseparator /> + + <vbox class="panel-subview-body" context="unified-extensions-context-menu"> + <html:div id="unified-extensions-messages-container"> + <!-- messages will be inserted here --> + </html:div> + + <vbox id="overflowed-extensions-list"> + <!-- overflowed extension buttons from the nav-bar will go here --> + </vbox> + + <vbox id="unified-extensions-area"> + <!-- default area for extension browser action buttons --> + </vbox> + + <vbox class="unified-extensions-list"> + <!-- active visible extensions go here --> + </vbox> + </vbox> + + <toolbarseparator /> + + <toolbarbutton id="unified-extensions-manage-extensions" + class="subviewbutton panel-subview-footer-button unified-extensions-manage-extensions" + data-l10n-id="unified-extensions-manage-extensions" + oncommand="BrowserOpenAddonsMgr('addons://list/extension');" /> + </panelview> + </panelmultiview> + </panel> +</html:template> + +<html:template id="panicButtonNotificationTemplate"> + <panel id="panic-button-success-notification" + type="arrow" + position="bottomright topright" + hidden="true" + role="alert" + orient="vertical"> + <hbox id="panic-button-success-header"> + <image id="panic-button-success-icon" alt=""/> + <vbox> + <description data-l10n-id="panic-button-thankyou-msg1"></description> + <description data-l10n-id="panic-button-thankyou-msg2"></description> + </vbox> + </hbox> + <button id="panic-button-success-closebutton" + data-l10n-id="panic-button-thankyou-button" + oncommand="PanicButtonNotifier.close()"/> + </panel> +</html:template> + +<html:template id="appMenuNotificationTemplate"> + <panel id="appMenu-notification-popup" + class="popup-notification-panel panel-no-padding" + type="arrow" + position="after_start" + flip="slide" + orient="vertical" + noautofocus="true" + noautohide="true" + nopreventnavboxhide="true" + role="alert"> + <popupnotification id="appMenu-update-available-notification" + popupid="update-available" + data-lazy-l10n-id="appmenu-update-available2" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + checkboxhidden="true" + buttonhighlight="true" + hasicon="true" + hidden="true"> + <popupnotificationcontent id="update-available-notification-content" orient="vertical"> + <description id="update-available-description" data-lazy-l10n-id="appmenu-update-available-message2"></description> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="appMenu-update-manual-notification" + popupid="update-manual" + data-lazy-l10n-id="appmenu-update-manual2" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + checkboxhidden="true" + buttonhighlight="true" + hasicon="true" + hidden="true"> + <popupnotificationcontent id="update-manual-notification-content" orient="vertical"> + <description id="update-manual-description" data-lazy-l10n-id="appmenu-update-manual-message2"></description> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="appMenu-update-unsupported-notification" + popupid="update-unsupported" + data-lazy-l10n-id="appmenu-update-unsupported2" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + checkboxhidden="true" + buttonhighlight="true" + hasicon="true" + hidden="true"> + <popupnotificationcontent id="update-unsupported-notification-content" orient="vertical"> + <description id="update-unsupported-description" data-lazy-l10n-id="appmenu-update-unsupported-message2"></description> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="appMenu-update-restart-notification" + popupid="update-restart" + data-lazy-l10n-id="appmenu-update-restart2" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + checkboxhidden="true" + buttonhighlight="true" + hasicon="true" + hidden="true"> + <popupnotificationcontent id="update-restart-notification-content" orient="vertical"> + <description id="update-restart-description" data-lazy-l10n-id="appmenu-update-restart-message2"></description> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="appMenu-update-other-instance-notification" + popupid="update-other-instance" + data-lazy-l10n-id="appmenu-update-other-instance" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + checkboxhidden="true" + buttonhighlight="true" + hasicon="true" + hidden="true"> + <popupnotificationcontent id="update-other-instance-notification-content" orient="vertical"> + <description id="update-other-instance-description" data-lazy-l10n-id="appmenu-update-other-instance-message"></description> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="appMenu-addon-installed-notification" + popupid="addon-installed" + closebuttonhidden="true" + secondarybuttonhidden="true" + data-lazy-l10n-id="appmenu-addon-private-browsing-installed2" + data-l10n-attrs="buttonlabel, buttonaccesskey" + dropmarkerhidden="true" + checkboxhidden="true" + buttonhighlight="true" + hidden="true"> + <popupnotificationcontent class="addon-installed-notification-content" orient="vertical"> + <description id="addon-install-description" data-lazy-l10n-id="appmenu-addon-post-install-message3"/> + <checkbox id="addon-incognito-checkbox" + data-lazy-l10n-id="appmenu-addon-post-install-incognito-checkbox"/> + </popupnotificationcontent> + </popupnotification> + </panel> +</html:template> + +<html:template id="customModeWrapper"> + <menupopup id="customizationPaletteItemContextMenu" + onpopupshowing="gCustomizeMode.onPaletteContextMenuShowing(event)"> + <menuitem oncommand="gCustomizeMode.addToToolbar(this.parentNode.triggerNode, 'palette-context')" + class="customize-context-addToToolbar" + data-l10n-id="customize-menu-add-to-toolbar"/> + <menuitem oncommand="gCustomizeMode.addToPanel(this.parentNode.triggerNode, 'palette-context')" + class="customize-context-addToPanel" + data-l10n-id="customize-menu-add-to-overflowmenu"/> + </menupopup> + + <panel id="downloads-button-autohide-panel" + role="group" + type="arrow" + onpopupshown="gCustomizeMode._downloadPanelAutoHideTimeout = setTimeout(() => event.target.hidePopup(), 4000);" + onmouseover="clearTimeout(gCustomizeMode._downloadPanelAutoHideTimeout);" + onmouseout="gCustomizeMode._downloadPanelAutoHideTimeout = setTimeout(() => event.target.hidePopup(), 2000);" + onpopuphidden="clearTimeout(gCustomizeMode._downloadPanelAutoHideTimeout);" + > + <checkbox id="downloads-button-autohide-checkbox" + data-l10n-id="customize-mode-downloads-button-autohide" checked="true" + oncommand="gCustomizeMode.onDownloadsAutoHideChange(event)"/> + </panel> +</html:template> + +<panel id="appMenu-popup" + class="cui-widget-panel panel-no-padding" + role="group" + type="arrow" + hidden="true" + flip="slide" + position="bottomright topright" + noautofocus="true"> + <panelmultiview id="appMenu-multiView" mainViewId="appMenu-protonMainView" + viewCacheId="appMenu-viewCache"> + </panelmultiview> +</panel> + +<html:template id="extensionNotificationTemplate"> + <panel id="extension-notification-panel" + class="popup-notification-panel panel-no-padding" + role="group" + type="arrow" + flip="slide" + position="bottomright topright" + tabspecific="true"> + <popupnotification id="extension-new-tab-notification" + class="extension-controlled-notification" + popupid="extension-new-tab" + hidden="true" + data-lazy-l10n-id="appmenu-new-tab-controlled-changes" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + buttonhighlight="true" + checkboxhidden="true"> + <popupnotificationcontent orient="vertical"> + <description id="extension-new-tab-notification-description"/> + </popupnotificationcontent> + </popupnotification> + <popupnotification id="extension-homepage-notification" + class="extension-controlled-notification" + popupid="extension-homepage" + hidden="true" + data-lazy-l10n-id="appmenu-homepage-controlled-changes" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + buttonhighlight="true" + checkboxhidden="true"> + <popupnotificationcontent orient="vertical"> + <description id="extension-homepage-notification-description"/> + </popupnotificationcontent> + </popupnotification> + <popupnotification id="extension-tab-hide-notification" + class="extension-controlled-notification" + popupid="extension-tab-hide" + hidden="true" + data-lazy-l10n-id="appmenu-tab-hide-controlled" + data-l10n-attrs="buttonlabel, buttonaccesskey, secondarybuttonlabel, secondarybuttonaccesskey" + closebuttonhidden="true" + dropmarkerhidden="true" + checkboxhidden="true"> + <popupnotificationcontent orient="vertical"> + <description id="extension-tab-hide-notification-description"/> + </popupnotificationcontent> + </popupnotification> + </panel> +</html:template> diff --git a/browser/components/customizableui/content/panelUI.js b/browser/components/customizableui/content/panelUI.js new file mode 100644 index 0000000000..f99560bd42 --- /dev/null +++ b/browser/components/customizableui/content/panelUI.js @@ -0,0 +1,1072 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +ChromeUtils.defineESModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.jsm", +}); + +/** + * Maintains the state and dispatches events for the main menu panel. + */ + +const PanelUI = { + /** Panel events that we listen for. **/ + get kEvents() { + return ["popupshowing", "popupshown", "popuphiding", "popuphidden"]; + }, + /** + * Used for lazily getting and memoizing elements from the document. Lazy + * getters are set in init, and memoizing happens after the first retrieval. + */ + get kElements() { + return { + multiView: "appMenu-multiView", + menuButton: "PanelUI-menu-button", + panel: "appMenu-popup", + overflowFixedList: "widget-overflow-fixed-list", + overflowPanel: "widget-overflow", + navbar: "nav-bar", + }; + }, + + _initialized: false, + _notifications: null, + _notificationPanel: null, + + init(shouldSuppress) { + this._shouldSuppress = shouldSuppress; + this._initElements(); + + this.menuButton.addEventListener("mousedown", this); + this.menuButton.addEventListener("keypress", this); + + Services.obs.addObserver(this, "fullscreen-nav-toolbox"); + Services.obs.addObserver(this, "appMenu-notifications"); + Services.obs.addObserver(this, "show-update-progress"); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "autoHideToolbarInFullScreen", + "browser.fullscreen.autohide", + false, + (pref, previousValue, newValue) => { + // On OSX, or with autohide preffed off, MozDOMFullscreen is the only + // event we care about, since fullscreen should behave just like non + // fullscreen. Otherwise, we don't want to listen to these because + // we'd just be spamming ourselves with both of them whenever a user + // opened a video. + if (newValue) { + window.removeEventListener("MozDOMFullscreen:Entered", this); + window.removeEventListener("MozDOMFullscreen:Exited", this); + window.addEventListener("fullscreen", this); + } else { + window.addEventListener("MozDOMFullscreen:Entered", this); + window.addEventListener("MozDOMFullscreen:Exited", this); + window.removeEventListener("fullscreen", this); + } + + this.updateNotifications(false); + }, + autoHidePref => autoHidePref && Services.appinfo.OS !== "Darwin" + ); + + if (this.autoHideToolbarInFullScreen) { + window.addEventListener("fullscreen", this); + } else { + window.addEventListener("MozDOMFullscreen:Entered", this); + window.addEventListener("MozDOMFullscreen:Exited", this); + } + + window.addEventListener("activate", this); + CustomizableUI.addListener(this); + + // We do this sync on init because in order to have the overflow button show up + // we need to know whether anything is in the permanent panel area. + this.overflowFixedList.hidden = false; + // Also unhide the separator. We use CSS to hide/show it based on the panel's content. + this.overflowFixedList.previousElementSibling.hidden = false; + CustomizableUI.registerPanelNode( + this.overflowFixedList, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + this.updateOverflowStatus(); + + Services.obs.notifyObservers( + null, + "appMenu-notifications-request", + "refresh" + ); + + this._initialized = true; + }, + + _initElements() { + for (let [k, v] of Object.entries(this.kElements)) { + // Need to do fresh let-bindings per iteration + let getKey = k; + let id = v; + this.__defineGetter__(getKey, function () { + delete this[getKey]; + return (this[getKey] = document.getElementById(id)); + }); + } + }, + + _eventListenersAdded: false, + _ensureEventListenersAdded() { + if (this._eventListenersAdded) { + return; + } + this._addEventListeners(); + }, + + _addEventListeners() { + for (let event of this.kEvents) { + this.panel.addEventListener(event, this); + } + + PanelMultiView.getViewNode(document, "PanelUI-helpView").addEventListener( + "ViewShowing", + this._onHelpViewShow + ); + this._eventListenersAdded = true; + }, + + _removeEventListeners() { + for (let event of this.kEvents) { + this.panel.removeEventListener(event, this); + } + PanelMultiView.getViewNode( + document, + "PanelUI-helpView" + ).removeEventListener("ViewShowing", this._onHelpViewShow); + this._eventListenersAdded = false; + }, + + uninit() { + this._removeEventListeners(); + + if (this._notificationPanel) { + for (let event of this.kEvents) { + this.notificationPanel.removeEventListener(event, this); + } + } + + Services.obs.removeObserver(this, "fullscreen-nav-toolbox"); + Services.obs.removeObserver(this, "appMenu-notifications"); + Services.obs.removeObserver(this, "show-update-progress"); + + window.removeEventListener("MozDOMFullscreen:Entered", this); + window.removeEventListener("MozDOMFullscreen:Exited", this); + window.removeEventListener("fullscreen", this); + window.removeEventListener("activate", this); + this.menuButton.removeEventListener("mousedown", this); + this.menuButton.removeEventListener("keypress", this); + CustomizableUI.removeListener(this); + if (this.whatsNewPanel) { + this.whatsNewPanel.removeEventListener("ViewShowing", this); + } + }, + + /** + * Opens the menu panel if it's closed, or closes it if it's + * open. + * + * @param aEvent the event that triggers the toggle. + */ + toggle(aEvent) { + // Don't show the panel if the window is in customization mode, + // since this button doubles as an exit path for the user in this case. + if (document.documentElement.hasAttribute("customizing")) { + return; + } + this._ensureEventListenersAdded(); + if (this.panel.state == "open") { + this.hide(); + } else if (this.panel.state == "closed") { + this.show(aEvent); + } + }, + + /** + * Opens the menu panel. If the event target has a child with the + * toolbarbutton-icon attribute, the panel will be anchored on that child. + * Otherwise, the panel is anchored on the event target itself. + * + * @param aEvent the event (if any) that triggers showing the menu. + */ + show(aEvent) { + this._ensureShortcutsShown(); + (async () => { + await this.ensureReady(); + + if ( + this.panel.state == "open" || + document.documentElement.hasAttribute("customizing") + ) { + return; + } + + let domEvent = null; + if (aEvent && aEvent.type != "command") { + domEvent = aEvent; + } + + let anchor = this._getPanelAnchor(this.menuButton); + await PanelMultiView.openPopup(this.panel, anchor, { + triggerEvent: domEvent, + }); + })().catch(console.error); + }, + + /** + * If the menu panel is being shown, hide it. + */ + hide() { + if (document.documentElement.hasAttribute("customizing")) { + return; + } + + PanelMultiView.hidePopup(this.panel); + }, + + observe(subject, topic, status) { + switch (topic) { + case "fullscreen-nav-toolbox": + if (this._notifications) { + this.updateNotifications(false); + } + break; + case "appMenu-notifications": + // Don't initialize twice. + if (status == "init" && this._notifications) { + break; + } + this._notifications = AppMenuNotifications.notifications; + this.updateNotifications(true); + break; + case "show-update-progress": + openAboutDialog(); + break; + } + }, + + handleEvent(aEvent) { + // Ignore context menus and menu button menus showing and hiding: + if (aEvent.type.startsWith("popup") && aEvent.target != this.panel) { + return; + } + switch (aEvent.type) { + case "popupshowing": + updateEditUIVisibility(); + // Fall through + case "popupshown": + if (aEvent.type == "popupshown") { + CustomizableUI.addPanelCloseListeners(this.panel); + } + // Fall through + case "popuphiding": + if (aEvent.type == "popuphiding") { + updateEditUIVisibility(); + } + // Fall through + case "popuphidden": + this.updateNotifications(); + this._updatePanelButton(aEvent.target); + if (aEvent.type == "popuphidden") { + CustomizableUI.removePanelCloseListeners(this.panel); + } + break; + case "mousedown": + // On Mac, ctrl-click will send a context menu event from the widget, so + // we don't want to bring up the panel when ctrl key is pressed. + if ( + aEvent.button == 0 && + (AppConstants.platform != "macosx" || !aEvent.ctrlKey) + ) { + this.toggle(aEvent); + } + break; + case "keypress": + if (aEvent.key == " " || aEvent.key == "Enter") { + this.toggle(aEvent); + aEvent.stopPropagation(); + } + break; + case "MozDOMFullscreen:Entered": + case "MozDOMFullscreen:Exited": + case "fullscreen": + case "activate": + this.updateNotifications(); + break; + case "ViewShowing": + if (aEvent.target == this.whatsNewPanel) { + this.onWhatsNewPanelShowing(); + } + break; + } + }, + + get isReady() { + return !!this._isReady; + }, + + get isNotificationPanelOpen() { + let panelState = this.notificationPanel.state; + + return panelState == "showing" || panelState == "open"; + }, + + /** + * Registering the menu panel is done lazily for performance reasons. This + * method is exposed so that CustomizationMode can force panel-readyness in the + * event that customization mode is started before the panel has been opened + * by the user. + * + * @param aCustomizing (optional) set to true if this was called while entering + * customization mode. If that's the case, we trust that customization + * mode will handle calling beginBatchUpdate and endBatchUpdate. + * + * @return a Promise that resolves once the panel is ready to roll. + */ + async ensureReady() { + if (this._isReady) { + return; + } + + await window.delayedStartupPromise; + this._ensureEventListenersAdded(); + this.panel.hidden = false; + this._isReady = true; + }, + + /** + * Switch the panel to the help view if it's not already + * in that view. + */ + showHelpView(aAnchor) { + this._ensureEventListenersAdded(); + this.multiView.showSubView("PanelUI-helpView", aAnchor); + }, + + /** + * Switch the panel to the "More Tools" view. + * + * @param moreTools The panel showing the "More Tools" view. + */ + showMoreToolsPanel(moreTools) { + this.showSubView("appmenu-moreTools", moreTools); + + // Notify DevTools the panel view is showing and need it to populate the + // "Browser Tools" section of the panel. We notify the observer setup by + // DevTools because we want to ensure the same menuitem list is shared + // between both the AppMenu and toolbar button views. + let view = document.getElementById("appmenu-developer-tools-view"); + Services.obs.notifyObservers(view, "web-developer-tools-view-showing"); + }, + + /** + * Shows a subview in the panel with a given ID. + * + * @param aViewId the ID of the subview to show. + * @param aAnchor the element that spawned the subview. + * @param aEvent the event triggering the view showing. + */ + async showSubView(aViewId, aAnchor, aEvent) { + if (aEvent) { + // On Mac, ctrl-click will send a context menu event from the widget, so + // we don't want to bring up the panel when ctrl key is pressed. + if ( + aEvent.type == "mousedown" && + (aEvent.button != 0 || + (AppConstants.platform == "macosx" && aEvent.ctrlKey)) + ) { + return; + } + if ( + aEvent.type == "keypress" && + aEvent.key != " " && + aEvent.key != "Enter" + ) { + return; + } + } + + this._ensureEventListenersAdded(); + + let viewNode = PanelMultiView.getViewNode(document, aViewId); + if (!viewNode) { + console.error("Could not show panel subview with id: ", aViewId); + return; + } + + if (!aAnchor) { + console.error( + "Expected an anchor when opening subview with id: ", + aViewId + ); + return; + } + + this.ensureWhatsNewInitialized(viewNode); + this.ensurePanicViewInitialized(viewNode); + + let container = aAnchor.closest("panelmultiview"); + if (container && !viewNode.hasAttribute("disallowSubView")) { + container.showSubView(aViewId, aAnchor); + } else if (!aAnchor.open) { + aAnchor.open = true; + + let tempPanel = document.createXULElement("panel"); + tempPanel.setAttribute("type", "arrow"); + tempPanel.setAttribute("id", "customizationui-widget-panel"); + if (viewNode.hasAttribute("neverhidden")) { + tempPanel.setAttribute("neverhidden", "true"); + } + + tempPanel.setAttribute("class", "cui-widget-panel panel-no-padding"); + tempPanel.setAttribute("viewId", aViewId); + if (aAnchor.getAttribute("tabspecific")) { + tempPanel.setAttribute("tabspecific", true); + } + if (aAnchor.getAttribute("locationspecific")) { + tempPanel.setAttribute("locationspecific", true); + } + if (this._disableAnimations) { + tempPanel.setAttribute("animate", "false"); + } + tempPanel.setAttribute("context", ""); + document + .getElementById(CustomizableUI.AREA_NAVBAR) + .appendChild(tempPanel); + + let multiView = document.createXULElement("panelmultiview"); + multiView.setAttribute("id", "customizationui-widget-multiview"); + multiView.setAttribute("viewCacheId", "appMenu-viewCache"); + multiView.setAttribute("mainViewId", viewNode.id); + multiView.appendChild(viewNode); + tempPanel.appendChild(multiView); + viewNode.classList.add("cui-widget-panelview", "PanelUI-subView"); + + let viewShown = false; + let panelRemover = event => { + // Avoid bubbled events triggering the panel closing. + if (event && event.target != tempPanel) { + return; + } + viewNode.classList.remove("cui-widget-panelview"); + if (viewShown) { + CustomizableUI.removePanelCloseListeners(tempPanel); + tempPanel.removeEventListener("popuphidden", panelRemover); + } + aAnchor.open = false; + + PanelMultiView.removePopup(tempPanel); + }; + + if (aAnchor.parentNode.id == "PersonalToolbar") { + tempPanel.classList.add("bookmarks-toolbar"); + } + + let anchor = this._getPanelAnchor(aAnchor); + + if (aAnchor != anchor && aAnchor.id) { + anchor.setAttribute("consumeanchor", aAnchor.id); + } + + try { + viewShown = await PanelMultiView.openPopup(tempPanel, anchor, { + position: "bottomright topright", + triggerEvent: aEvent, + }); + } catch (ex) { + console.error(ex); + } + + if (viewShown) { + CustomizableUI.addPanelCloseListeners(tempPanel); + tempPanel.addEventListener("popuphidden", panelRemover); + } else { + panelRemover(); + } + } + }, + + /** + * Sets up the event listener for when the What's New panel is shown. + * + * @param {panelview} panelView The What's New panelview. + */ + ensureWhatsNewInitialized(panelView) { + if (panelView.id != "PanelUI-whatsNew" || panelView._initialized) { + return; + } + + if (!this.whatsNewPanel) { + this.whatsNewPanel = panelView; + } + + panelView._initialized = true; + panelView.addEventListener("ViewShowing", this); + }, + + /** + * Adds FTL before appending the panic view markup to the main DOM. + * + * @param {panelview} panelView The Panic View panelview. + */ + ensurePanicViewInitialized(panelView) { + if (panelView.id != "PanelUI-panicView" || panelView._initialized) { + return; + } + + if (!this.panic) { + this.panic = panelView; + } + + MozXULElement.insertFTLIfNeeded("browser/panicButton.ftl"); + panelView._initialized = true; + }, + + /** + * When the What's New panel is showing, we fetch the messages to show. + */ + onWhatsNewPanelShowing() { + ToolbarPanelHub.renderMessages( + window, + document, + "PanelUI-whatsNew-message-container" + ); + }, + + /** + * NB: The enable- and disableSingleSubviewPanelAnimations methods only + * affect the hiding/showing animations of single-subview panels (tempPanel + * in the showSubView method). + */ + disableSingleSubviewPanelAnimations() { + this._disableAnimations = true; + }, + + enableSingleSubviewPanelAnimations() { + this._disableAnimations = false; + }, + + updateOverflowStatus() { + let hasKids = this.overflowFixedList.hasChildNodes(); + if (hasKids && !this.navbar.hasAttribute("nonemptyoverflow")) { + this.navbar.setAttribute("nonemptyoverflow", "true"); + this.overflowPanel.setAttribute("hasfixeditems", "true"); + } else if (!hasKids && this.navbar.hasAttribute("nonemptyoverflow")) { + PanelMultiView.hidePopup(this.overflowPanel); + this.overflowPanel.removeAttribute("hasfixeditems"); + this.navbar.removeAttribute("nonemptyoverflow"); + } + }, + + onWidgetAfterDOMChange(aNode, aNextNode, aContainer, aWasRemoval) { + if (aContainer == this.overflowFixedList) { + this.updateOverflowStatus(); + } + }, + + onAreaReset(aArea, aContainer) { + if (aContainer == this.overflowFixedList) { + this.updateOverflowStatus(); + } + }, + + /** + * Sets the anchor node into the open or closed state, depending + * on the state of the panel. + */ + _updatePanelButton() { + let { state } = this.panel; + if (state == "open" || state == "showing") { + this.menuButton.open = true; + document.l10n.setAttributes( + this.menuButton, + "appmenu-menu-button-opened2" + ); + } else { + this.menuButton.open = false; + document.l10n.setAttributes( + this.menuButton, + "appmenu-menu-button-closed2" + ); + } + }, + + _onHelpViewShow(aEvent) { + // Call global menu setup function + buildHelpMenu(); + + let helpMenu = document.getElementById("menu_HelpPopup"); + let items = this.getElementsByTagName("vbox")[0]; + let attrs = [ + "command", + "oncommand", + "onclick", + "key", + "disabled", + "accesskey", + "label", + ]; + + // Remove all buttons from the view + while (items.firstChild) { + items.firstChild.remove(); + } + + // Add the current set of menuitems of the Help menu to this view + let menuItems = Array.prototype.slice.call( + helpMenu.getElementsByTagName("menuitem") + ); + let fragment = document.createDocumentFragment(); + for (let node of menuItems) { + if (node.hidden) { + continue; + } + let button = document.createXULElement("toolbarbutton"); + // Copy specific attributes from a menuitem of the Help menu + for (let attrName of attrs) { + if (!node.hasAttribute(attrName)) { + continue; + } + button.setAttribute(attrName, node.getAttribute(attrName)); + } + + // We have AppMenu-specific strings for the Help menu. By convention, + // their localization IDs are set on "appmenu-data-l10n-id" attributes. + let l10nId = node.getAttribute("appmenu-data-l10n-id"); + if (l10nId) { + document.l10n.setAttributes(button, l10nId); + } + + if (node.id) { + button.id = "appMenu_" + node.id; + } + + button.classList.add("subviewbutton"); + fragment.appendChild(button); + } + + // The Enterprise Support menu item has a different location than its + // placement in the menubar, so we need to specify it here. + let helpPolicySupport = fragment.querySelector( + "#appMenu_helpPolicySupport" + ); + if (helpPolicySupport) { + fragment.insertBefore( + helpPolicySupport, + fragment.querySelector("#appMenu_menu_HelpPopup_reportPhishingtoolmenu") + .nextSibling + ); + } + + items.appendChild(fragment); + }, + + _hidePopup() { + if (!this._notificationPanel) { + return; + } + + if (this.isNotificationPanelOpen) { + this.notificationPanel.hidePopup(); + } + }, + + /** + * Selects and marks an item by id from the main view. The ids are an array, + * the first in the main view and the later ids in subsequent subviews that + * become marked when the user opens the subview. The subview marking is + * cancelled if a different subview is opened. + */ + async selectAndMarkItem(itemIds) { + // This shouldn't really occur, but return early just in case. + if (document.documentElement.hasAttribute("customizing")) { + return; + } + + // This function was triggered from a button while the menu was + // already open, so the panel should be in the process of hiding. + // Wait for the panel to hide first, then reopen it. + if (this.panel.state == "hiding") { + await new Promise(resolve => { + this.panel.addEventListener("popuphidden", resolve, { once: true }); + }); + } + + if (this.panel.state != "open") { + await new Promise(resolve => { + this.panel.addEventListener("ViewShown", resolve, { once: true }); + this.show(); + }); + } + + let currentView; + + let viewShownCB = event => { + viewHidingCB(); + + if (itemIds.length) { + let subItem = window.document.getElementById(itemIds[0]); + if (event.target.id == subItem?.closest("panelview")?.id) { + Services.tm.dispatchToMainThread(() => { + markItem(event.target); + }); + } else { + itemIds = []; + } + } + }; + + let viewHidingCB = () => { + if (currentView) { + currentView.ignoreMouseMove = false; + } + currentView = null; + }; + + let popupHiddenCB = () => { + viewHidingCB(); + this.panel.removeEventListener("ViewShown", viewShownCB); + }; + + let markItem = viewNode => { + let id = itemIds.shift(); + let item = window.document.getElementById(id); + item.setAttribute("tabindex", "-1"); + + currentView = PanelView.forNode(viewNode); + currentView.selectedElement = item; + currentView.focusSelectedElement(true); + + // Prevent the mouse from changing the highlight temporarily. + // This flag gets removed when the view is hidden or a key + // is pressed. + currentView.ignoreMouseMove = true; + + if (itemIds.length) { + this.panel.addEventListener("ViewShown", viewShownCB, { once: true }); + } + this.panel.addEventListener("ViewHiding", viewHidingCB, { once: true }); + }; + + this.panel.addEventListener("popuphidden", popupHiddenCB, { once: true }); + markItem(this.mainView); + }, + + updateNotifications(notificationsChanged) { + let notifications = this._notifications; + if (!notifications || !notifications.length) { + if (notificationsChanged) { + this._clearAllNotifications(); + this._hidePopup(); + } + return; + } + + if ( + (window.fullScreen && FullScreen.navToolboxHidden) || + document.fullscreenElement || + this._shouldSuppress() + ) { + this._hidePopup(); + return; + } + + let doorhangers = notifications.filter( + n => !n.dismissed && !n.options.badgeOnly + ); + + if (this.panel.state == "showing" || this.panel.state == "open") { + // If the menu is already showing, then we need to dismiss all + // notifications since we don't want their doorhangers competing for + // attention. Don't hide the badge though; it isn't really in competition + // with anything. + doorhangers.forEach(n => { + n.dismissed = true; + if (n.options.onDismissed) { + n.options.onDismissed(window); + } + }); + this._hidePopup(); + if (!notifications[0].options.badgeOnly) { + this._showBannerItem(notifications[0]); + } + } else if (doorhangers.length) { + // Only show the doorhanger if the window is focused and not fullscreen + if ( + (window.fullScreen && this.autoHideToolbarInFullScreen) || + Services.focus.activeWindow !== window + ) { + this._hidePopup(); + this._showBadge(doorhangers[0]); + this._showBannerItem(doorhangers[0]); + } else { + this._clearBadge(); + this._showNotificationPanel(doorhangers[0]); + } + } else { + this._hidePopup(); + this._showBadge(notifications[0]); + this._showBannerItem(notifications[0]); + } + }, + + _showNotificationPanel(notification) { + this._refreshNotificationPanel(notification); + + if (this.isNotificationPanelOpen) { + return; + } + + if (notification.options.beforeShowDoorhanger) { + notification.options.beforeShowDoorhanger(document); + } + + let anchor = this._getPanelAnchor(this.menuButton); + + // Insert Fluent files when needed before notification is opened + MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); + MozXULElement.insertFTLIfNeeded("browser/appMenuNotifications.ftl"); + + // After Fluent files are loaded into document replace data-lazy-l10n-ids with actual ones + document + .getElementById("appMenu-notification-popup") + .querySelectorAll("[data-lazy-l10n-id]") + .forEach(el => { + el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); + el.removeAttribute("data-lazy-l10n-id"); + }); + + this.notificationPanel.openPopup(anchor, "bottomright topright"); + }, + + _clearNotificationPanel() { + for (let popupnotification of this.notificationPanel.children) { + popupnotification.hidden = true; + popupnotification.notification = null; + } + }, + + _clearAllNotifications() { + this._clearNotificationPanel(); + this._clearBadge(); + this._clearBannerItem(); + }, + + get notificationPanel() { + // Lazy load the panic-button-success-notification panel the first time we need to display it. + if (!this._notificationPanel) { + let template = document.getElementById("appMenuNotificationTemplate"); + template.replaceWith(template.content); + this._notificationPanel = document.getElementById( + "appMenu-notification-popup" + ); + for (let event of this.kEvents) { + this._notificationPanel.addEventListener(event, this); + } + } + return this._notificationPanel; + }, + + get mainView() { + if (!this._mainView) { + this._mainView = PanelMultiView.getViewNode( + document, + "appMenu-protonMainView" + ); + } + return this._mainView; + }, + + get addonNotificationContainer() { + if (!this._addonNotificationContainer) { + this._addonNotificationContainer = PanelMultiView.getViewNode( + document, + "appMenu-proton-addon-banners" + ); + } + + return this._addonNotificationContainer; + }, + + _formatDescriptionMessage(n) { + let text = {}; + let array = n.options.message.split("<>"); + text.start = array[0] || ""; + text.name = n.options.name || ""; + text.end = array[1] || ""; + return text; + }, + + _refreshNotificationPanel(notification) { + this._clearNotificationPanel(); + + let popupnotificationID = this._getPopupId(notification); + let popupnotification = document.getElementById(popupnotificationID); + + popupnotification.setAttribute("id", popupnotificationID); + popupnotification.setAttribute( + "buttoncommand", + "PanelUI._onNotificationButtonEvent(event, 'buttoncommand');" + ); + popupnotification.setAttribute( + "secondarybuttoncommand", + "PanelUI._onNotificationButtonEvent(event, 'secondarybuttoncommand');" + ); + + if (notification.options.message) { + let desc = this._formatDescriptionMessage(notification); + popupnotification.setAttribute("label", desc.start); + popupnotification.setAttribute("name", desc.name); + popupnotification.setAttribute("endlabel", desc.end); + } + if (notification.options.onRefresh) { + notification.options.onRefresh(window); + } + if (notification.options.popupIconURL) { + popupnotification.setAttribute("icon", notification.options.popupIconURL); + popupnotification.setAttribute("hasicon", true); + } + if (notification.options.learnMoreURL) { + popupnotification.setAttribute( + "learnmoreurl", + notification.options.learnMoreURL + ); + } + + popupnotification.notification = notification; + popupnotification.show(); + }, + + _showBadge(notification) { + let badgeStatus = this._getBadgeStatus(notification); + this.menuButton.setAttribute("badge-status", badgeStatus); + }, + + // "Banner item" here refers to an item in the hamburger panel menu. They will + // typically show up as a colored row in the panel. + _showBannerItem(notification) { + const supportedIds = [ + "update-downloading", + "update-available", + "update-manual", + "update-unsupported", + "update-restart", + ]; + if (!supportedIds.includes(notification.id)) { + return; + } + + if (!this._panelBannerItem) { + this._panelBannerItem = this.mainView.querySelector(".panel-banner-item"); + } + + let l10nId = "appmenuitem-banner-" + notification.id; + document.l10n.setAttributes(this._panelBannerItem, l10nId); + + this._panelBannerItem.setAttribute("notificationid", notification.id); + this._panelBannerItem.hidden = false; + this._panelBannerItem.notification = notification; + }, + + _clearBadge() { + this.menuButton.removeAttribute("badge-status"); + }, + + _clearBannerItem() { + if (this._panelBannerItem) { + this._panelBannerItem.notification = null; + this._panelBannerItem.hidden = true; + } + }, + + _onNotificationButtonEvent(event, type) { + let notificationEl = getNotificationFromElement(event.originalTarget); + + if (!notificationEl) { + throw new Error( + "PanelUI._onNotificationButtonEvent: couldn't find notification element" + ); + } + + if (!notificationEl.notification) { + throw new Error( + "PanelUI._onNotificationButtonEvent: couldn't find notification" + ); + } + + let notification = notificationEl.notification; + + if (type == "secondarybuttoncommand") { + AppMenuNotifications.callSecondaryAction(window, notification); + } else { + AppMenuNotifications.callMainAction(window, notification, true); + } + }, + + _onBannerItemSelected(event) { + let target = event.originalTarget; + if (!target.notification) { + throw new Error( + "menucommand target has no associated action/notification" + ); + } + + event.stopPropagation(); + AppMenuNotifications.callMainAction(window, target.notification, false); + }, + + _getPopupId(notification) { + return "appMenu-" + notification.id + "-notification"; + }, + + _getBadgeStatus(notification) { + return notification.id; + }, + + _getPanelAnchor(candidate) { + let iconAnchor = candidate.badgeStack || candidate.icon; + return iconAnchor || candidate; + }, + + _ensureShortcutsShown(view = this.mainView) { + if (view.hasAttribute("added-shortcuts")) { + return; + } + view.setAttribute("added-shortcuts", "true"); + for (let button of view.querySelectorAll("toolbarbutton[key]")) { + let keyId = button.getAttribute("key"); + let key = document.getElementById(keyId); + if (!key) { + continue; + } + button.setAttribute("shortcut", ShortcutUtils.prettifyShortcut(key)); + } + }, +}; + +XPCOMUtils.defineConstant(this, "PanelUI", PanelUI); + +/** + * Gets the currently selected locale for display. + * @return the selected locale + */ +function getLocale() { + return Services.locale.appLocaleAsBCP47; +} + +/** + * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>. + */ +function getNotificationFromElement(aElement) { + return aElement.closest("popupnotification"); +} diff --git a/browser/components/customizableui/moz.build b/browser/components/customizableui/moz.build new file mode 100644 index 0000000000..5d1e0e4061 --- /dev/null +++ b/browser/components/customizableui/moz.build @@ -0,0 +1,28 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "content", +] + +BROWSER_CHROME_MANIFESTS += ["test/browser.toml"] +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +TESTING_JS_MODULES += [ + "test/CustomizableUITestUtils.sys.mjs", +] + +EXTRA_JS_MODULES += [ + "CustomizableUI.sys.mjs", + "CustomizableWidgets.sys.mjs", + "CustomizeMode.sys.mjs", + "DragPositionManager.sys.mjs", + "PanelMultiView.sys.mjs", + "SearchWidgetTracker.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Toolbars and Customization") diff --git a/browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs b/browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs new file mode 100644 index 0000000000..2cb4e13f99 --- /dev/null +++ b/browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Shared functions generally available for tests involving PanelMultiView and + * the CustomizableUI elements in the browser window. + */ + +import { Assert } from "resource://testing-common/Assert.sys.mjs"; + +import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs"; +import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", +}); + +export class CustomizableUITestUtils { + /** + * Constructs an instance that operates with the specified browser window. + */ + constructor(window) { + this.window = window; + this.document = window.document; + this.PanelUI = window.PanelUI; + } + + /** + * Opens a closed PanelMultiView via the specified function while waiting for + * the main view with the specified ID to become fully interactive. + */ + async openPanelMultiView(panel, mainView, openFn) { + if (panel.state == "open") { + // Some tests may intermittently leave the panel open. We report this, but + // don't fail so we don't introduce new intermittent test failures. + Assert.ok( + true, + "A previous test left the panel open. This should be" + + " fixed, but we can still do a best-effort recovery and" + + " assume that the requested view will be made visible." + ); + await openFn(); + return; + } + + if (panel.state == "hiding") { + // There may still be tests that don't wait after invoking a command that + // causes the main menu panel to close. Depending on timing, the panel may + // or may not be fully closed when the following test runs. We handle this + // case gracefully so we don't risk introducing new intermittent test + // failures that may show up at a later time. + Assert.ok( + true, + "A previous test requested the panel to close but" + + " didn't wait for the operation to complete. While" + + " the test should be fixed, we can still continue." + ); + } else { + Assert.equal(panel.state, "closed", "The panel is closed to begin with."); + } + + let promiseShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown"); + await openFn(); + await promiseShown; + } + + /** + * Closes an open PanelMultiView via the specified function while waiting for + * the operation to complete. + */ + async hidePanelMultiView(panel, closeFn) { + Assert.ok(panel.state == "open", "The panel is open to begin with."); + + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + await closeFn(); + await promiseHidden; + } + + /** + * Opens the main menu and waits for it to become fully interactive. + */ + async openMainMenu() { + await this.openPanelMultiView( + this.PanelUI.panel, + this.PanelUI.mainView, + () => this.PanelUI.show() + ); + } + + /** + * Closes the main menu and waits for the operation to complete. + */ + async hideMainMenu() { + await this.hidePanelMultiView(this.PanelUI.panel, () => + this.PanelUI.hide() + ); + } + + /** + * Add the search bar into the nav bar and verify it does not overflow. + * + * @returns {Promise} + * @resolves The search bar element. + * @rejects If search bar is not found, or overflows. + */ + async addSearchBar() { + lazy.CustomizableUI.addWidgetToArea( + "search-container", + lazy.CustomizableUI.AREA_NAVBAR, + lazy.CustomizableUI.getPlacementOfWidget("urlbar-container").position + 1 + ); + + // addWidgetToArea adds the search bar into the nav bar first. If the + // search bar overflows, OverflowableToolbar for the nav bar moves the + // search bar into the overflow panel in its overflow event handler + // asynchronously. + // + // We should first wait for the layout flush to make sure either the search + // bar fits into the nav bar, or overflow event gets dispatched and the + // overflow event handler is called. + await this.window.promiseDocumentFlushed(() => {}); + + // Check if the OverflowableToolbar is handling the overflow event. + let navbar = this.window.document.getElementById( + lazy.CustomizableUI.AREA_NAVBAR + ); + await TestUtils.waitForCondition(() => { + return !navbar.overflowable.isHandlingOverflow(); + }); + + let searchbar = this.window.document.getElementById("searchbar"); + if (!searchbar) { + throw new Error("The search bar should exist."); + } + + // If the search bar overflows, it's placed inside the overflow panel. + // + // We cannot use navbar's property to check if overflow happens, since it + // can be different widget than the search bar that overflows. + if (searchbar.closest("#widget-overflow")) { + throw new Error( + "The search bar should not overflow from the nav bar. " + + "This test fails if the screen resolution is small and " + + "the search bar overflows from the nav bar." + ); + } + + return searchbar; + } + + removeSearchBar() { + lazy.CustomizableUI.removeWidgetFromArea("search-container"); + } +} diff --git a/browser/components/customizableui/test/browser.toml b/browser/components/customizableui/test/browser.toml new file mode 100644 index 0000000000..9eb0db9b5b --- /dev/null +++ b/browser/components/customizableui/test/browser.toml @@ -0,0 +1,368 @@ +[DEFAULT] +support-files = [ + "head.js", + "support/test_967000_charEncoding_page.html", +] +prefs = [ + "browser.sessionstore.closedTabsFromAllWindows=true", + "browser.sessionstore.closedTabsFromClosedWindows=true", +] + +["browser_1003588_no_specials_in_panel.js"] + +["browser_1008559_anchor_undo_restore.js"] + +["browser_1042100_default_placements_update.js"] + +["browser_1058573_showToolbarsDropdown.js"] + +["browser_1087303_button_fullscreen.js"] +tags = "fullscreen" +skip-if = ["os == 'mac'"] + +["browser_1087303_button_preferences.js"] + +["browser_1089591_still_customizable_after_reset.js"] + +["browser_1096763_seen_widgets_post_reset.js"] + +["browser_1161838_inserted_new_default_buttons.js"] +skip-if = ["verify"] + +["browser_1484275_PanelMultiView_toggle_with_other_popup.js"] + +["browser_1701883_restore_defaults_pocket_pref.js"] + +["browser_1702200_PanelMultiView_header_separator.js"] + +["browser_1795260_searchbar_overflow_toolbar.js"] +tags = "overflowable-toolbar" + +["browser_1856572_ensure_Fluent_works_in_customizeMode.js"] +# Bug 1856572: Causes a drag-drop native loop assertion failure on debug +# MacOS builds in browser_876926_customize_mode_wrapping.js +skip-if = ["os == 'mac' && debug"] + +["browser_694291_searchbar_preference.js"] + +["browser_873501_handle_specials.js"] + +["browser_876926_customize_mode_wrapping.js"] +skip-if = ["os == 'linux' && !debug"] # Bug 1682752 + +["browser_876944_customize_mode_create_destroy.js"] + +["browser_877006_missing_view.js"] + +["browser_877178_unregisterArea.js"] + +["browser_877447_skip_missing_ids.js"] + +["browser_878452_drag_to_panel.js"] + +["browser_884402_customize_from_overflow.js"] +tags = "overflowable-toolbar" + +["browser_885052_customize_mode_observers_disabed.js"] +tags = "fullscreen" + +["browser_885530_showInPrivateBrowsing.js"] + +["browser_886323_buildArea_removable_nodes.js"] + +["browser_890262_destroyWidget_after_add_to_panel.js"] + +["browser_892955_isWidgetRemovable_for_removed_widgets.js"] + +["browser_892956_destroyWidget_defaultPlacements.js"] + +["browser_901207_searchbar_in_panel.js"] + +["browser_909779_overflow_toolbars_new_window.js"] +tags = "overflowable-toolbar" +skip-if = ["os == 'linux'"] + +["browser_913972_currentset_overflow.js"] +tags = "overflowable-toolbar" + +["browser_914138_widget_API_overflowable_toolbar.js"] +tags = "overflowable-toolbar" +skip-if = ["os == 'linux'"] + +["browser_918049_skipintoolbarset_dnd.js"] + +["browser_923857_customize_mode_event_wrapping_during_reset.js"] + +["browser_927717_customize_drag_empty_toolbar.js"] + +["browser_934113_menubar_removable.js"] +# Because this test is about the menubar, it can't be run on mac +skip-if = ["os == 'mac'"] + +["browser_934951_zoom_in_toolbar.js"] + +["browser_938980_navbar_collapsed.js"] + +["browser_938995_indefaultstate_nonremovable.js"] + +["browser_940013_registerToolbarNode_calls_registerArea.js"] + +["browser_940307_panel_click_closure_handling.js"] +skip-if = ["verify && debug && os == 'linux'"] + +["browser_940946_removable_from_navbar_customizemode.js"] + +["browser_941083_invalidate_wrapper_cache_createWidget.js"] +skip-if = ["verify"] + +["browser_942581_unregisterArea_keeps_placements.js"] + +["browser_944887_destroyWidget_should_destroy_in_palette.js"] + +["browser_945739_showInPrivateBrowsing_customize_mode.js"] + +["browser_947914_button_copy.js"] + +["browser_947914_button_cut.js"] + +["browser_947914_button_find.js"] + +["browser_947914_button_history.js"] +https_first_disabled = true +support-files = ["dummy_history_item.html"] + +["browser_947914_button_newPrivateWindow.js"] + +["browser_947914_button_newWindow.js"] + +["browser_947914_button_paste.js"] + +["browser_947914_button_print.js"] + +["browser_947914_button_zoomIn.js"] + +["browser_947914_button_zoomOut.js"] + +["browser_947914_button_zoomReset.js"] +skip-if = ["os == 'linux' && debug"] # Intermittent failures + +["browser_947987_removable_default.js"] + +["browser_948985_non_removable_defaultArea.js"] + +["browser_952963_areaType_getter_no_area.js"] +skip-if = ["verify"] + +["browser_956602_remove_special_widget.js"] + +["browser_962069_drag_to_overflow_chevron.js"] +tags = "overflowable-toolbar" + +["browser_963639_customizing_attribute_non_customizable_toolbar.js"] + +["browser_968565_insert_before_hidden_items.js"] + +["browser_969427_recreate_destroyed_widget_after_reset.js"] + +["browser_969661_character_encoding_navbar_disabled.js"] + +["browser_970511_undo_restore_default.js"] +skip-if = ["verify"] + +["browser_972267_customizationchange_events.js"] + +["browser_976792_insertNodeInWindow.js"] +tags = "overflowable-toolbar" +skip-if = ["os == 'linux'"] + +["browser_978084_dragEnd_after_move.js"] +skip-if = ["verify"] + +["browser_980155_add_overflow_toolbar.js"] +tags = "overflowable-toolbar" +skip-if = ["verify"] + +["browser_981305_separator_insertion.js"] + +["browser_981418-widget-onbeforecreated-handler.js"] +skip-if = ["verify"] + +["browser_982656_restore_defaults_builtin_widgets.js"] + +["browser_984455_bookmarks_items_reparenting.js"] + +["browser_985815_propagate_setToolbarVisibility.js"] + +["browser_987177_destroyWidget_xul.js"] +skip-if = ["verify"] + +["browser_987177_xul_wrapper_updating.js"] + +["browser_987492_window_api.js"] + +["browser_987640_charEncoding.js"] + +["browser_989338_saved_placements_not_resaved.js"] + +["browser_989751_subviewbutton_class.js"] + +["browser_992747_toggle_noncustomizable_toolbar.js"] + +["browser_993322_widget_notoolbar.js"] +skip-if = ["verify"] + +["browser_995164_registerArea_during_customize_mode.js"] + +["browser_996364_registerArea_different_properties.js"] + +["browser_996635_remove_non_widgets.js"] + +["browser_PanelMultiView.js"] +# Unit tests for the PanelMultiView module. These are independent from +# CustomizableUI, but are located here together with the module they're testing. + +["browser_PanelMultiView_focus.js"] + +["browser_PanelMultiView_keyboard.js"] + +["browser_addons_area.js"] + +["browser_allow_dragging_removable_false.js"] + +["browser_backfwd_enabled_post_customize.js"] + +["browser_bookmarks_empty_message.js"] + +["browser_bookmarks_toolbar_collapsed_restore_default.js"] + +["browser_bookmarks_toolbar_shown_newtab.js"] + +["browser_bootstrapped_custom_toolbar.js"] + +["browser_check_tooltips_in_navbar.js"] + +["browser_create_button_widget.js"] + +["browser_ctrl_click_panel_opening.js"] + +["browser_currentset_post_reset.js"] + +["browser_customization_context_menus.js"] + +["browser_customizemode_contextmenu_menubuttonstate.js"] + +["browser_customizemode_lwthemes.js"] + +["browser_customizemode_uidensity.js"] + +["browser_disable_commands_customize.js"] + +["browser_drag_outside_palette.js"] + +["browser_editcontrols_update.js"] + +["browser_exit_background_customize_mode.js"] +https_first_disabled = true + +["browser_flexible_space_area.js"] + +["browser_help_panel_cloning.js"] + +["browser_hidden_widget_overflow.js"] + +["browser_history_after_appMenu.js"] + +["browser_history_recently_closed.js"] + +["browser_history_recently_closed_middleclick.js"] +https_first_disabled = true + +["browser_history_restore_session.js"] + +["browser_insert_before_moved_node.js"] + +["browser_menubar_visibility.js"] +skip-if = ["os == 'mac'"] # no toggle-able menubar on macOS. + +["browser_newtab_button_customizemode.js"] + +["browser_open_from_popup.js"] + +["browser_open_in_lazy_tab.js"] + +["browser_overflow_use_subviews.js"] +tags = "overflowable-toolbar" +skip-if = ["verify"] + +["browser_palette_labels.js"] + +["browser_panelUINotifications.js"] + +["browser_panelUINotifications_bannerVisibility.js"] + +["browser_panelUINotifications_fullscreen.js"] +tags = "fullscreen" +skip-if = ["os == 'mac'"] + +["browser_panelUINotifications_fullscreen_noAutoHideToolbar.js"] +skip-if = ["verify && (os == 'linux' || os == 'mac')"] +tags = "fullscreen" + +["browser_panelUINotifications_modals.js"] + +["browser_panelUINotifications_multiWindow.js"] + +["browser_panel_keyboard_navigation.js"] + +["browser_panel_locationSpecific.js"] + +["browser_panel_menulist.js"] + +["browser_panel_toggle.js"] + +["browser_proton_moreTools_panel.js"] + +["browser_proton_toolbar_hide_toolbarbuttons.js"] + +["browser_registerArea.js"] + +["browser_reload_tab.js"] + +["browser_remote_attribute.js"] + +["browser_remote_tabs_button.js"] +skip-if = ["(verify && debug && (os == 'linux' || os == 'mac'))"] + +["browser_remove_customized_specials.js"] + +["browser_reset_builtin_widget_currentArea.js"] + +["browser_reset_dom_events.js"] + +["browser_screenshot_button_disabled.js"] + +["browser_searchbar_removal.js"] + +["browser_sidebar_toggle.js"] +skip-if = ["verify"] + +["browser_switch_to_customize_mode.js"] + +["browser_synced_tabs_menu.js"] +fail-if = ["a11y_checks"] # Bug 1854536 clicked #PanelUI-remotetabs-connect-device-button may not be focusable + +["browser_tabbar_big_widgets.js"] + +["browser_toolbar_collapsed_states.js"] + +["browser_touchbar_customization.js"] +skip-if = [ + "os == 'linux'", + "os == 'win'", +] + +["browser_unified_extensions_reset.js"] + +["browser_widget_animation.js"] + +["browser_widget_recreate_events.js"] diff --git a/browser/components/customizableui/test/browser_1003588_no_specials_in_panel.js b/browser/components/customizableui/test/browser_1003588_no_specials_in_panel.js new file mode 100644 index 0000000000..5aa2860827 --- /dev/null +++ b/browser/components/customizableui/test/browser_1003588_no_specials_in_panel.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function simulateItemDragAndEnd(aToDrag, aTarget) { + var ds = Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); + + ds.startDragSessionForTests( + Ci.nsIDragService.DRAGDROP_ACTION_MOVE | + Ci.nsIDragService.DRAGDROP_ACTION_COPY | + Ci.nsIDragService.DRAGDROP_ACTION_LINK + ); + try { + var [result, dataTransfer] = EventUtils.synthesizeDragOver( + aToDrag.parentNode, + aTarget + ); + EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, aTarget); + // Send dragend to move dragging item back to initial place. + EventUtils.sendDragEvent( + { type: "dragend", dataTransfer }, + aToDrag.parentNode + ); + } finally { + ds.endDragSession(true); + } +} + +add_task(async function checkNoAddingToPanel() { + let area = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL; + let previousPlacements = getAreaWidgetIds(area); + CustomizableUI.addWidgetToArea("separator", area); + CustomizableUI.addWidgetToArea("spring", area); + CustomizableUI.addWidgetToArea("spacer", area); + assertAreaPlacements(area, previousPlacements); + + let oldNumberOfItems = previousPlacements.length; + if (getAreaWidgetIds(area).length != oldNumberOfItems) { + CustomizableUI.reset(); + } +}); + +add_task(async function checkAddingToToolbar() { + let area = CustomizableUI.AREA_NAVBAR; + let previousPlacements = getAreaWidgetIds(area); + CustomizableUI.addWidgetToArea("separator", area); + CustomizableUI.addWidgetToArea("spring", area); + CustomizableUI.addWidgetToArea("spacer", area); + let expectedPlacements = [...previousPlacements].concat([ + /separator/, + /spring/, + /spacer/, + ]); + assertAreaPlacements(area, expectedPlacements); + + let newlyAddedElements = getAreaWidgetIds(area).slice(-3); + while (newlyAddedElements.length) { + CustomizableUI.removeWidgetFromArea(newlyAddedElements.shift()); + } + + assertAreaPlacements(area, previousPlacements); + + let oldNumberOfItems = previousPlacements.length; + if (getAreaWidgetIds(area).length != oldNumberOfItems) { + CustomizableUI.reset(); + } +}); + +add_task(async function checkDragging() { + let startArea = CustomizableUI.AREA_TABSTRIP; + let targetArea = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL; + let startingToolbarPlacements = getAreaWidgetIds(startArea); + let startingTargetPlacements = getAreaWidgetIds(targetArea); + + CustomizableUI.addWidgetToArea("separator", startArea); + CustomizableUI.addWidgetToArea("spring", startArea); + CustomizableUI.addWidgetToArea("spacer", startArea); + + let placementsWithSpecials = getAreaWidgetIds(startArea); + let elementsToMove = []; + for (let id of placementsWithSpecials) { + if (CustomizableUI.isSpecialWidget(id)) { + elementsToMove.push(id); + } + } + is(elementsToMove.length, 3, "Should have 3 elements to try and drag."); + + await startCustomizing(); + let existingSpecial = null; + existingSpecial = + gCustomizeMode.visiblePalette.querySelector("toolbarspring"); + ok( + existingSpecial, + "Should have a flexible space in the palette by default in photon" + ); + for (let id of elementsToMove) { + simulateItemDragAndEnd( + document.getElementById(id), + document.getElementById(targetArea) + ); + } + + assertAreaPlacements(startArea, placementsWithSpecials); + assertAreaPlacements(targetArea, startingTargetPlacements); + + for (let id of elementsToMove) { + simulateItemDrag( + document.getElementById(id), + gCustomizeMode.visiblePalette + ); + } + + assertAreaPlacements(startArea, startingToolbarPlacements); + assertAreaPlacements(targetArea, startingTargetPlacements); + + let allSpecials = gCustomizeMode.visiblePalette.querySelectorAll( + "toolbarspring,toolbarseparator,toolbarspacer" + ); + allSpecials = [...allSpecials].filter(special => special != existingSpecial); + ok( + !allSpecials.length, + "No (new) specials should make it to the palette alive." + ); + await endCustomizing(); +}); + +add_task(async function asyncCleanup() { + await endCustomizing(); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_1008559_anchor_undo_restore.js b/browser/components/customizableui/test/browser_1008559_anchor_undo_restore.js new file mode 100644 index 0000000000..a7da97cc95 --- /dev/null +++ b/browser/components/customizableui/test/browser_1008559_anchor_undo_restore.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kAnchorAttribute = "cui-anchorid"; + +/** + * Check that anchor gets set correctly when moving an item from the panel to the toolbar + * and into the palette. + */ +add_task(async function () { + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await startCustomizing(); + let button = document.getElementById("history-panelmenu"); + is( + button.getAttribute(kAnchorAttribute), + "nav-bar-overflow-button", + "Button (" + button.id + ") starts out with correct anchor" + ); + + let navbar = CustomizableUI.getCustomizationTarget( + document.getElementById("nav-bar") + ); + let onMouseUp = BrowserTestUtils.waitForEvent(navbar, "mouseup"); + simulateItemDrag(button, navbar); + await onMouseUp; + is( + CustomizableUI.getPlacementOfWidget(button.id).area, + "nav-bar", + "Button (" + button.id + ") ends up in nav-bar" + ); + + ok( + !button.hasAttribute(kAnchorAttribute), + "Button (" + button.id + ") has no anchor in toolbar" + ); + + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + is( + button.getAttribute(kAnchorAttribute), + "nav-bar-overflow-button", + "Button (" + button.id + ") has anchor again" + ); + + let resetButton = document.getElementById("customization-reset-button"); + ok(!resetButton.hasAttribute("disabled"), "Should be able to reset now."); + await gCustomizeMode.reset(); + + ok( + !button.hasAttribute(kAnchorAttribute), + "Button (" + button.id + ") once again has no anchor in customize panel" + ); + + await endCustomizing(); +}); + +/** + * Check that anchor gets set correctly when moving an item from the panel to the toolbar + * using 'reset' + */ +add_task(async function () { + await startCustomizing(); + let button = document.getElementById("stop-reload-button"); + ok( + !button.hasAttribute(kAnchorAttribute), + "Button (" + button.id + ") has no anchor in toolbar" + ); + + let panel = document.getElementById(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL); + let onMouseUp = BrowserTestUtils.waitForEvent(panel, "mouseup"); + simulateItemDrag(button, panel); + await onMouseUp; + is( + CustomizableUI.getPlacementOfWidget(button.id).area, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + "Button (" + button.id + ") ends up in panel" + ); + is( + button.getAttribute(kAnchorAttribute), + "nav-bar-overflow-button", + "Button (" + button.id + ") has correct anchor in the panel" + ); + + let resetButton = document.getElementById("customization-reset-button"); + ok(!resetButton.hasAttribute("disabled"), "Should be able to reset now."); + await gCustomizeMode.reset(); + + ok( + !button.hasAttribute(kAnchorAttribute), + "Button (" + button.id + ") once again has no anchor in toolbar" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_1042100_default_placements_update.js b/browser/components/customizableui/test/browser_1042100_default_placements_update.js new file mode 100644 index 0000000000..5c011a5ccd --- /dev/null +++ b/browser/components/customizableui/test/browser_1042100_default_placements_update.js @@ -0,0 +1,241 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getSavedStatePlacements(area) { + return CustomizableUI.getTestOnlyInternalProp("gSavedState").placements[area]; +} + +// NB: This uses some ugly hacks to get into the CUI module from elsewhere... +// don't try this at home, kids. +function test() { + // Customize something to make sure stuff changed: + CustomizableUI.addWidgetToArea( + "save-page-button", + CustomizableUI.AREA_NAVBAR + ); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + registerCleanupFunction(() => + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState) + ); + + let gFuturePlacements = + CustomizableUI.getTestOnlyInternalProp("gFuturePlacements"); + is( + gFuturePlacements.size, + 0, + "All future placements should be dealt with by now." + ); + + let gPalette = CustomizableUI.getTestOnlyInternalProp("gPalette"); + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + CustomizableUIInternal._updateForNewVersion(); + is(gFuturePlacements.size, 0, "No change to future placements initially."); + + let currentVersion = CustomizableUI.getTestOnlyInternalProp("kVersion"); + + // Add our widget to the defaults: + let testWidgetNew = { + id: "test-messing-with-default-placements-new", + label: "Test messing with default placements - should be inserted", + defaultArea: CustomizableUI.AREA_NAVBAR, + introducedInVersion: currentVersion + 1, + }; + + let normalizedWidget = CustomizableUIInternal.normalizeWidget( + testWidgetNew, + CustomizableUI.SOURCE_BUILTIN + ); + ok(normalizedWidget, "Widget should be normalizable"); + if (!normalizedWidget) { + return; + } + gPalette.set(testWidgetNew.id, normalizedWidget); + + let testWidgetOld = { + id: "test-messing-with-default-placements-old", + label: "Test messing with default placements - should NOT be inserted", + defaultArea: CustomizableUI.AREA_NAVBAR, + introducedInVersion: currentVersion, + }; + + normalizedWidget = CustomizableUIInternal.normalizeWidget( + testWidgetOld, + CustomizableUI.SOURCE_BUILTIN + ); + ok(normalizedWidget, "Widget should be normalizable"); + if (!normalizedWidget) { + return; + } + gPalette.set(testWidgetOld.id, normalizedWidget); + + // Now increase the version in the module: + CustomizableUI.setTestOnlyInternalProp( + "kVersion", + CustomizableUI.getTestOnlyInternalProp("kVersion") + 1 + ); + + let hadSavedState = !!CustomizableUI.getTestOnlyInternalProp("gSavedState"); + if (!hadSavedState) { + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + currentVersion: CustomizableUI.getTestOnlyInternalProp("kVersion") - 1, + }); + } + + // Then call the re-init routine so we re-add the builtin widgets + CustomizableUIInternal._updateForNewVersion(); + is(gFuturePlacements.size, 1, "Should have 1 more future placement"); + let haveNavbarPlacements = gFuturePlacements.has(CustomizableUI.AREA_NAVBAR); + ok(haveNavbarPlacements, "Should have placements for nav-bar"); + if (haveNavbarPlacements) { + let placements = [...gFuturePlacements.get(CustomizableUI.AREA_NAVBAR)]; + + // Ignore widgets that are placed using the pref facility and not the + // versioned facility. They're independent of kVersion and the saved + // state's current version, so they may be present in the placements. + for (let i = 0; i < placements.length; ) { + if (placements[i] == testWidgetNew.id) { + i++; + continue; + } + let pref = "browser.toolbarbuttons.introduced." + placements[i]; + let introduced = Services.prefs.getBoolPref(pref, false); + if (!introduced) { + i++; + continue; + } + placements.splice(i, 1); + } + + is(placements.length, 1, "Should have 1 newly placed widget in nav-bar"); + is( + placements[0], + testWidgetNew.id, + "Should have our test widget to be placed in nav-bar" + ); + } + + // Reset kVersion + CustomizableUI.setTestOnlyInternalProp( + "kVersion", + CustomizableUI.getTestOnlyInternalProp("kVersion") - 1 + ); + + // Now test that the builtin photon migrations work: + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + currentVersion: 6, + placements: { + "nav-bar": ["urlbar-container", "bookmarks-menu-button"], + "PanelUI-contents": ["panic-button", "edit-controls"], + }, + }); + Services.prefs.setIntPref("browser.proton.toolbar.version", 0); + CustomizableUIInternal._updateForNewVersion(); + CustomizableUIInternal._updateForNewProtonVersion(); + { + let navbarPlacements = getSavedStatePlacements("nav-bar"); + let springs = navbarPlacements.filter(id => id.includes("spring")); + is(springs.length, 2, "Should have 2 toolbarsprings in placements now"); + navbarPlacements = navbarPlacements.filter(id => !id.includes("spring")); + Assert.deepEqual( + navbarPlacements, + [ + "back-button", + "forward-button", + "stop-reload-button", + "urlbar-container", + "downloads-button", + "fxa-toolbar-menu-button", + "reset-pbm-toolbar-button", + ], + "Nav-bar placements should be correct." + ); + + Assert.deepEqual(getSavedStatePlacements("widget-overflow-fixed-list"), [ + "panic-button", + ]); + } + + // Finally, test the downloads and fxa avatar button migrations work. + let oldNavbarPlacements = [ + "urlbar-container", + "customizableui-special-spring3", + "search-container", + ]; + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + currentVersion: 10, + placements: { + "nav-bar": Array.from(oldNavbarPlacements), + "widget-overflow-fixed-list": ["downloads-button"], + }, + }); + CustomizableUIInternal._updateForNewVersion(); + Assert.deepEqual( + getSavedStatePlacements("nav-bar"), + oldNavbarPlacements.concat([ + "downloads-button", + "fxa-toolbar-menu-button", + "reset-pbm-toolbar-button", + ]), + "Downloads button inserted in navbar" + ); + Assert.deepEqual( + getSavedStatePlacements("widget-overflow-fixed-list"), + [], + "Overflow panel is empty" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + currentVersion: 10, + placements: { + "nav-bar": ["downloads-button"].concat(oldNavbarPlacements), + }, + }); + CustomizableUIInternal._updateForNewVersion(); + Assert.deepEqual( + getSavedStatePlacements("nav-bar"), + oldNavbarPlacements.concat([ + "downloads-button", + "fxa-toolbar-menu-button", + "reset-pbm-toolbar-button", + ]), + "Downloads button reinserted in navbar" + ); + + oldNavbarPlacements = [ + "urlbar-container", + "customizableui-special-spring3", + "search-container", + "other-widget", + ]; + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + currentVersion: 10, + placements: { + "nav-bar": Array.from(oldNavbarPlacements), + }, + }); + CustomizableUIInternal._updateForNewVersion(); + let expectedNavbarPlacements = [ + "urlbar-container", + "customizableui-special-spring3", + "search-container", + "downloads-button", + "other-widget", + "fxa-toolbar-menu-button", + "reset-pbm-toolbar-button", + ]; + Assert.deepEqual( + getSavedStatePlacements("nav-bar"), + expectedNavbarPlacements, + "Downloads button inserted in navbar before other widgets" + ); + + gFuturePlacements.delete(CustomizableUI.AREA_NAVBAR); + gPalette.delete(testWidgetNew.id); + gPalette.delete(testWidgetOld.id); +} diff --git a/browser/components/customizableui/test/browser_1058573_showToolbarsDropdown.js b/browser/components/customizableui/test/browser_1058573_showToolbarsDropdown.js new file mode 100644 index 0000000000..0e57ef8a28 --- /dev/null +++ b/browser/components/customizableui/test/browser_1058573_showToolbarsDropdown.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + info("Check that toggleable toolbars dropdown in always shown"); + + info("Remove all possible custom toolbars"); + await removeCustomToolbars(); + + info("Enter customization mode"); + await startCustomizing(); + + let toolbarsToggle = document.getElementById( + "customization-toolbar-visibility-button" + ); + ok(toolbarsToggle, "The toolbars toggle dropdown exists"); + ok( + !toolbarsToggle.hasAttribute("hidden"), + "The toolbars toggle dropdown is displayed" + ); +}); + +add_task(async function asyncCleanup() { + info("Exit customization mode"); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_1087303_button_fullscreen.js b/browser/components/customizableui/test/browser_1087303_button_fullscreen.js new file mode 100644 index 0000000000..f67e81b892 --- /dev/null +++ b/browser/components/customizableui/test/browser_1087303_button_fullscreen.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +add_task(async function () { + info("Check fullscreen button existence and functionality"); + + CustomizableUI.addWidgetToArea( + "fullscreen-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + + let fullscreenButton = document.getElementById("fullscreen-button"); + ok(fullscreenButton, "Fullscreen button appears in Panel Menu"); + + let fullscreenPromise = promiseFullscreenChange(); + fullscreenButton.click(); + await fullscreenPromise; + + ok(window.fullScreen, "Fullscreen mode was opened"); + + // exit full screen mode + fullscreenPromise = promiseFullscreenChange(); + window.fullScreen = !window.fullScreen; + await fullscreenPromise; + + ok(!window.fullScreen, "Successfully exited fullscreen"); +}); + +function promiseFullscreenChange() { + return new Promise((resolve, reject) => { + info("Wait for fullscreen change"); + + let timeoutId = setTimeout(() => { + window.removeEventListener("fullscreen", onFullscreenChange, true); + reject("Fullscreen change did not happen within " + 20000 + "ms"); + }, 20000); + + function onFullscreenChange(event) { + clearTimeout(timeoutId); + window.removeEventListener("fullscreen", onFullscreenChange, true); + info("Fullscreen event received"); + resolve(); + } + window.addEventListener("fullscreen", onFullscreenChange, true); + }); +} diff --git a/browser/components/customizableui/test/browser_1087303_button_preferences.js b/browser/components/customizableui/test/browser_1087303_button_preferences.js new file mode 100644 index 0000000000..7db48341cb --- /dev/null +++ b/browser/components/customizableui/test/browser_1087303_button_preferences.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +var newTab = null; + +add_task(async function () { + info("Check preferences button existence and functionality"); + CustomizableUI.addWidgetToArea( + "preferences-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let preferencesButton = document.getElementById("preferences-button"); + ok(preferencesButton, "Preferences button exists in Panel Menu"); + preferencesButton.click(); + + newTab = gBrowser.selectedTab; + await waitForPageLoad(newTab); + + let openedPage = gBrowser.currentURI.spec; + is(openedPage, "about:preferences", "Preferences page was opened"); +}); + +add_task(function asyncCleanup() { + if (gBrowser.tabs.length == 1) { + BrowserTestUtils.addTab(gBrowser, "about:blank"); + } + + gBrowser.removeTab(gBrowser.selectedTab); + info("Tabs were restored"); +}); + +function waitForPageLoad(aTab) { + return new Promise((resolve, reject) => { + let timeoutId = setTimeout(() => { + aTab.linkedBrowser.removeEventListener("load", onTabLoad, true); + reject("Page didn't load within " + 20000 + "ms"); + }, 20000); + + async function onTabLoad(event) { + clearTimeout(timeoutId); + aTab.linkedBrowser.removeEventListener("load", onTabLoad, true); + info("Tab event received: load"); + resolve(); + } + + aTab.linkedBrowser.addEventListener("load", onTabLoad, true, true); + }); +} diff --git a/browser/components/customizableui/test/browser_1089591_still_customizable_after_reset.js b/browser/components/customizableui/test/browser_1089591_still_customizable_after_reset.js new file mode 100644 index 0000000000..b0bbbd726c --- /dev/null +++ b/browser/components/customizableui/test/browser_1089591_still_customizable_after_reset.js @@ -0,0 +1,24 @@ +"use strict"; + +// Dragging the elements again after a reset should work +add_task(async function () { + await startCustomizing(); + let historyButton = document.getElementById("wrapper-history-panelmenu"); + let devButton = document.getElementById("wrapper-developer-button"); + + ok(historyButton && devButton, "Draggable elements should exist"); + simulateItemDrag(historyButton, devButton); + await gCustomizeMode.reset(); + ok(CustomizableUI.inDefaultState, "Should be back in default state"); + + historyButton = document.getElementById("wrapper-history-panelmenu"); + devButton = document.getElementById("wrapper-developer-button"); + ok(historyButton && devButton, "Draggable elements should exist"); + simulateItemDrag(historyButton, devButton); + + await endCustomizing(); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_1096763_seen_widgets_post_reset.js b/browser/components/customizableui/test/browser_1096763_seen_widgets_post_reset.js new file mode 100644 index 0000000000..74854f499c --- /dev/null +++ b/browser/components/customizableui/test/browser_1096763_seen_widgets_post_reset.js @@ -0,0 +1,41 @@ +"use strict"; + +const BUTTONID = "test-seenwidget-post-reset"; + +add_task(async function () { + CustomizableUI.createWidget({ + id: BUTTONID, + label: "Test widget seen post reset", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + const kPrefCustomizationState = "browser.uiCustomization.state"; + ok( + CustomizableUI.getTestOnlyInternalProp("gSeenWidgets").has(BUTTONID), + "Widget should be seen after createWidget is called." + ); + CustomizableUI.reset(); + ok( + CustomizableUI.getTestOnlyInternalProp("gSeenWidgets").has(BUTTONID), + "Widget should still be seen after reset." + ); + CustomizableUI.addWidgetToArea(BUTTONID, CustomizableUI.AREA_NAVBAR); + gCustomizeMode.removeFromArea(document.getElementById(BUTTONID)); + let hasUserValue = Services.prefs.prefHasUserValue(kPrefCustomizationState); + ok(hasUserValue, "Pref should be set right now."); + if (hasUserValue) { + let seenArray = JSON.parse( + Services.prefs.getCharPref(kPrefCustomizationState) + ).seen; + isnot( + seenArray.indexOf(BUTTONID), + -1, + "Widget should be in saved 'seen' list." + ); + } +}); + +registerCleanupFunction(function () { + CustomizableUI.destroyWidget(BUTTONID); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_1161838_inserted_new_default_buttons.js b/browser/components/customizableui/test/browser_1161838_inserted_new_default_buttons.js new file mode 100644 index 0000000000..b9501e94f8 --- /dev/null +++ b/browser/components/customizableui/test/browser_1161838_inserted_new_default_buttons.js @@ -0,0 +1,109 @@ +"use strict"; + +// NB: This uses some ugly hacks to get into the CUI module from elsewhere... +// don't try this at home, kids. +function test() { + // Customize something to make sure stuff changed: + CustomizableUI.addWidgetToArea( + "save-page-button", + CustomizableUI.AREA_NAVBAR + ); + + let gFuturePlacements = + CustomizableUI.getTestOnlyInternalProp("gFuturePlacements"); + is( + gFuturePlacements.size, + 0, + "All future placements should be dealt with by now." + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + // Force us to have a saved state: + CustomizableUIInternal.saveState(); + CustomizableUIInternal.loadSavedState(); + + CustomizableUIInternal._updateForNewVersion(); + is(gFuturePlacements.size, 0, "No change to future placements initially."); + + // Add our widget to the defaults: + let testWidgetNew = { + id: "test-messing-with-default-placements-new-pref", + label: "Test messing with default placements - pref-based", + defaultArea: CustomizableUI.AREA_NAVBAR, + introducedInVersion: "pref", + }; + + let normalizedWidget = CustomizableUIInternal.normalizeWidget( + testWidgetNew, + CustomizableUI.SOURCE_BUILTIN + ); + ok(normalizedWidget, "Widget should be normalizable"); + if (!normalizedWidget) { + return; + } + let gPalette = CustomizableUI.getTestOnlyInternalProp("gPalette"); + gPalette.set(testWidgetNew.id, normalizedWidget); + + // Now adjust default placements for area: + let navbarArea = CustomizableUI.getTestOnlyInternalProp("gAreas").get( + CustomizableUI.AREA_NAVBAR + ); + let navbarPlacements = navbarArea.get("defaultPlacements"); + navbarPlacements.splice( + navbarPlacements.indexOf("bookmarks-menu-button") + 1, + 0, + testWidgetNew.id + ); + + let savedPlacements = + CustomizableUI.getTestOnlyInternalProp("gSavedState").placements[ + CustomizableUI.AREA_NAVBAR + ]; + // Then call the re-init routine so we re-add the builtin widgets + CustomizableUIInternal._updateForNewVersion(); + is(gFuturePlacements.size, 1, "Should have 1 more future placement"); + let futureNavbarPlacements = gFuturePlacements.get( + CustomizableUI.AREA_NAVBAR + ); + ok(futureNavbarPlacements, "Should have placements for nav-bar"); + if (futureNavbarPlacements) { + ok( + futureNavbarPlacements.has(testWidgetNew.id), + "widget should be in future placements" + ); + } + CustomizableUIInternal._placeNewDefaultWidgetsInArea( + CustomizableUI.AREA_NAVBAR + ); + + let indexInSavedPlacements = savedPlacements.indexOf(testWidgetNew.id); + info("Saved placements: " + savedPlacements.join(", ")); + isnot(indexInSavedPlacements, -1, "Widget should have been inserted"); + is( + indexInSavedPlacements, + savedPlacements.indexOf("bookmarks-menu-button") + 1, + "Widget should be in the right place." + ); + + if (futureNavbarPlacements) { + ok( + !futureNavbarPlacements.has(testWidgetNew.id), + "widget should be out of future placements" + ); + } + + if (indexInSavedPlacements != -1) { + savedPlacements.splice(indexInSavedPlacements, 1); + } + + gFuturePlacements.delete(CustomizableUI.AREA_NAVBAR); + let indexInDefaultPlacements = navbarPlacements.indexOf(testWidgetNew.id); + if (indexInDefaultPlacements != -1) { + navbarPlacements.splice(indexInDefaultPlacements, 1); + } + gPalette.delete(testWidgetNew.id); + CustomizableUI.reset(); +} diff --git a/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js b/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js new file mode 100644 index 0000000000..89b86dba20 --- /dev/null +++ b/browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "data:text/html,<html><body></body></html>"; + +/** + * Test steps that may lead to the panel being stuck on Windows (bug 1484275). + */ +add_task(async function test_PanelMultiView_toggle_with_other_popup() { + // For proper cleanup, create a bookmark that we will remove later. + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + }); + registerCleanupFunction(() => PlacesUtils.bookmarks.remove(bookmark)); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // 1. Open the main menu. + await gCUITestUtils.openMainMenu(); + + // 2. Open another popup not managed by PanelMultiView. + StarUI._createPanelIfNeeded(); + let bookmarkPanel = document.getElementById("editBookmarkPanel"); + let shown = BrowserTestUtils.waitForEvent(bookmarkPanel, "popupshown"); + let hidden = BrowserTestUtils.waitForEvent(bookmarkPanel, "popuphidden"); + EventUtils.synthesizeKey("D", { accelKey: true }); + await shown; + + // 3. Click the button to which the main menu is anchored. We need a native + // mouse event to simulate the exact platform behavior with popups. + let clickFn = () => + EventUtils.promiseNativeMouseEventAndWaitForEvent({ + type: "click", + target: document.getElementById("PanelUI-button"), + atCenter: true, + eventTypeToWait: "mouseup", + }); + + // On Windows and macOS, the operation will close both popups. + if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, clickFn); + await new Promise(resolve => executeSoon(resolve)); + + // 4. Test that the popup can be opened again after it's been closed. + await gCUITestUtils.openMainMenu(); + Assert.equal(PanelUI.panel.state, "open"); + } else { + // On other platforms, the operation will close both popups and reopen the + // main menu immediately, so we wait for the reopen to occur. + shown = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + clickFn(); + await shown; + } + + await gCUITestUtils.hideMainMenu(); + + // Make sure the events for the bookmarks panel have also been processed + // before closing the tab and removing the bookmark. + await hidden; + } + ); +}); diff --git a/browser/components/customizableui/test/browser_1701883_restore_defaults_pocket_pref.js b/browser/components/customizableui/test/browser_1701883_restore_defaults_pocket_pref.js new file mode 100644 index 0000000000..a2085958fd --- /dev/null +++ b/browser/components/customizableui/test/browser_1701883_restore_defaults_pocket_pref.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Turning off Pocket pref should still be considered default state. +add_task(async function () { + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + Assert.ok( + Services.prefs.getBoolPref("extensions.pocket.enabled"), + "Pocket feature is enabled by default" + ); + + Services.prefs.setBoolPref("extensions.pocket.enabled", false); + + ok(CustomizableUI.inDefaultState, "Should still be default state"); + await resetCustomization(); + + Assert.ok( + !Services.prefs.getBoolPref("extensions.pocket.enabled"), + "Pocket feature is still off" + ); + ok(CustomizableUI.inDefaultState, "Should still be default state"); + + Services.prefs.setBoolPref("extensions.pocket.enabled", true); +}); diff --git a/browser/components/customizableui/test/browser_1702200_PanelMultiView_header_separator.js b/browser/components/customizableui/test/browser_1702200_PanelMultiView_header_separator.js new file mode 100644 index 0000000000..471d33c37a --- /dev/null +++ b/browser/components/customizableui/test/browser_1702200_PanelMultiView_header_separator.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests whether the separator insertion works correctly for the + * special case where we remove the content except the header itself + * before showing the panel view. + */ + +const TEST_SITE = "http://127.0.0.1"; +const RECENTLY_CLOSED_TABS_PANEL_ID = "appMenu-library-recentlyClosedTabs"; +const RECENTLY_CLOSED_TABS_ITEM_ID = "appMenuRecentlyClosedTabs"; + +function assertHeaderSeparator() { + let header = document.querySelector( + `#${RECENTLY_CLOSED_TABS_PANEL_ID} .panel-header` + ); + Assert.equal( + header.nextSibling.tagName, + "toolbarseparator", + "toolbarseparator should be shown below header" + ); +} + +/** + * Open and close a tab so we can access the "Recently + * closed tabs" panel + */ +add_task(async function test_setup() { + let tab = BrowserTestUtils.addTab(gBrowser, TEST_SITE); + gBrowser.selectedTab = tab; + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser, false, null, true); + await BrowserTestUtils.removeTab(tab); +}); + +/** + * Tests whether the toolbarseparator is shown correctly + * after re-entering same sub view, see bug 1702200 + * + * - App Menu + * - History + * - Recently closed tabs + */ +add_task(async function test_header_toolbarseparator() { + await gCUITestUtils.openMainMenu(); + + let historyView = PanelMultiView.getViewNode(document, "PanelUI-history"); + document.getElementById("appMenu-history-button").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Open Recently Closed Tabs and make sure there is a header separator + let closedTabsView = PanelMultiView.getViewNode( + document, + RECENTLY_CLOSED_TABS_PANEL_ID + ); + Assert.ok(!document.getElementById(RECENTLY_CLOSED_TABS_ITEM_ID).disabled); + document.getElementById(RECENTLY_CLOSED_TABS_ITEM_ID).click(); + await BrowserTestUtils.waitForEvent(closedTabsView, "ViewShown"); + assertHeaderSeparator(); + + // Go back and re-open the same view, header separator should be + // re-added as well + document + .querySelector(`#${RECENTLY_CLOSED_TABS_PANEL_ID} .subviewbutton-back`) + .click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + document.getElementById(RECENTLY_CLOSED_TABS_ITEM_ID).click(); + await BrowserTestUtils.waitForEvent(closedTabsView, "ViewShown"); + assertHeaderSeparator(); + + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/customizableui/test/browser_1795260_searchbar_overflow_toolbar.js b/browser/components/customizableui/test/browser_1795260_searchbar_overflow_toolbar.js new file mode 100644 index 0000000000..e26c3ff612 --- /dev/null +++ b/browser/components/customizableui/test/browser_1795260_searchbar_overflow_toolbar.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const WIDGET_ID = "search-container"; + +registerCleanupFunction(() => { + CustomizableUI.reset(); + Services.prefs.clearUserPref("browser.search.widget.inNavBar"); +}); + +add_task(async function test_syncPreferenceWithWidget() { + // Move the searchbar to the nav bar. + CustomizableUI.addWidgetToArea(WIDGET_ID, CustomizableUI.AREA_NAVBAR); + + let container = document.getElementById(WIDGET_ID); + // Set a disproportionately large width, which could be from a saved bigger + // window, or what not. + let width = window.innerWidth * 2; + container.setAttribute("width", width); + container.style.width = `${width}px`; + + // Stuff shouldn't overflow. + Assert.less( + container.getBoundingClientRect().width, + window.innerWidth, + "Searchbar shouldn't overflow" + ); +}); diff --git a/browser/components/customizableui/test/browser_1856572_ensure_Fluent_works_in_customizeMode.js b/browser/components/customizableui/test/browser_1856572_ensure_Fluent_works_in_customizeMode.js new file mode 100644 index 0000000000..0f89bd5d1c --- /dev/null +++ b/browser/components/customizableui/test/browser_1856572_ensure_Fluent_works_in_customizeMode.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { + openFirefoxViewTab, + withFirefoxView, + init: FirefoxViewTestUtilsInit, +} = ChromeUtils.importESModule( + "resource://testing-common/FirefoxViewTestUtils.sys.mjs" +); + +add_task(async function test_data_l10n_customize_mode() { + FirefoxViewTestUtilsInit(this); + await withFirefoxView({ win: window }, async function (browser) { + /** + * Bug 1856572, Bug 1857622: Without requesting two animation frames + * the "missing Fluent strings" issue will not reproduce. + */ + await new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(r)) + ); + await startCustomizing(); + await endCustomizing(); + await openFirefoxViewTab(window); + + const { document } = browser.contentWindow; + let header = document.querySelector("h1"); + document.l10n.setAttributes(header, "firefoxview-overview-header"); + let previousText = await document.l10n.formatValue( + "firefoxview-page-title" + ); + /** + * This should be replaced with + * BrowserTestUtils.waitForMutationCondition(header, {characterData: true}, ...) + * but apparently Fluent manipulation of textContent doesn't result + * in a characterData mutation occurring. + */ + await BrowserTestUtils.waitForCondition(() => { + return header.textContent != previousText; + }, "waiting for text content to change"); + + Assert.equal( + header.getAttribute("data-l10n-id"), + "firefoxview-overview-header", + "data-l10n-id should be updated" + ); + Assert.notEqual( + previousText, + header.textContent, + "The header's text content should be updated" + ); + let translatedText = await window.content.document.l10n.formatValue( + "firefoxview-overview-header" + ); + Assert.equal( + translatedText, + header.textContent, + "The changed text should be the translated value of 'firefoxview-overview-header" + ); + }); +}); diff --git a/browser/components/customizableui/test/browser_694291_searchbar_preference.js b/browser/components/customizableui/test/browser_694291_searchbar_preference.js new file mode 100644 index 0000000000..f65d8f0adc --- /dev/null +++ b/browser/components/customizableui/test/browser_694291_searchbar_preference.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const WIDGET_ID = "search-container"; +const PREF_NAME = "browser.search.widget.inNavBar"; + +function checkDefaults() { + ok(!Services.prefs.getBoolPref(PREF_NAME)); + is(CustomizableUI.getPlacementOfWidget(WIDGET_ID), null); +} + +add_task(async function test_defaults() { + // Verify the default state before the first test. + checkDefaults(); +}); + +add_task(async function test_syncPreferenceWithWidget() { + // Moving the widget to any position in the navigation toolbar should turn the + // preference to true. + CustomizableUI.addWidgetToArea(WIDGET_ID, CustomizableUI.AREA_NAVBAR); + ok(Services.prefs.getBoolPref(PREF_NAME)); + + // Moving the widget to any position outside of the navigation toolbar should + // turn the preference back to false. + CustomizableUI.addWidgetToArea( + WIDGET_ID, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + ok(!Services.prefs.getBoolPref(PREF_NAME)); +}); + +add_task(async function test_syncWidgetWithPreference() { + // setting the preference should move the widget to the navigation toolbar and + // place it right after the location bar. + Services.prefs.setBoolPref(PREF_NAME, true); + let placement = CustomizableUI.getPlacementOfWidget(WIDGET_ID); + is(placement.area, CustomizableUI.AREA_NAVBAR); + is( + placement.position, + CustomizableUI.getPlacementOfWidget("urlbar-container").position + 1 + ); + + // This should move the widget back to the customization palette. + Services.prefs.setBoolPref(PREF_NAME, false); + checkDefaults(); +}); diff --git a/browser/components/customizableui/test/browser_873501_handle_specials.js b/browser/components/customizableui/test/browser_873501_handle_specials.js new file mode 100644 index 0000000000..1711aee392 --- /dev/null +++ b/browser/components/customizableui/test/browser_873501_handle_specials.js @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbarName = "test-specials-toolbar"; + +registerCleanupFunction(removeCustomToolbars); + +// Add a toolbar with two springs and the downloads button. +add_task(async function addToolbarWith2SpringsAndDownloadsButton() { + // Create the toolbar with a single spring: + createToolbarWithPlacements(kToolbarName, ["spring"]); + ok(document.getElementById(kToolbarName), "Toolbar should be created."); + + // Check it's there with a generated ID: + assertAreaPlacements(kToolbarName, [/customizableui-special-spring\d+/]); + let [springId] = getAreaWidgetIds(kToolbarName); + + // Add a second spring, check if that's there and doesn't share IDs + CustomizableUI.addWidgetToArea("spring", kToolbarName); + assertAreaPlacements(kToolbarName, [ + springId, + /customizableui-special-spring\d+/, + ]); + let [, spring2Id] = getAreaWidgetIds(kToolbarName); + + isnot(springId, spring2Id, "Springs shouldn't have identical IDs."); + + // Try moving the downloads button to this new toolbar, between the two springs: + CustomizableUI.addWidgetToArea("downloads-button", kToolbarName, 1); + assertAreaPlacements(kToolbarName, [springId, "downloads-button", spring2Id]); + await removeCustomToolbars(); +}); + +// Add separators around the downloads button. +add_task(async function addSeparatorsAroundDownloadsButton() { + createToolbarWithPlacements(kToolbarName, ["separator"]); + ok(document.getElementById(kToolbarName), "Toolbar should be created."); + + // Check it's there with a generated ID: + assertAreaPlacements(kToolbarName, [/customizableui-special-separator\d+/]); + let [separatorId] = getAreaWidgetIds(kToolbarName); + + CustomizableUI.addWidgetToArea("separator", kToolbarName); + assertAreaPlacements(kToolbarName, [ + separatorId, + /customizableui-special-separator\d+/, + ]); + let [, separator2Id] = getAreaWidgetIds(kToolbarName); + + isnot(separatorId, separator2Id, "Separator ids shouldn't be equal."); + + CustomizableUI.addWidgetToArea("downloads-button", kToolbarName, 1); + assertAreaPlacements(kToolbarName, [ + separatorId, + "downloads-button", + separator2Id, + ]); + await removeCustomToolbars(); +}); + +// Add spacers around the downloads button. +add_task(async function addSpacersAroundDownloadsButton() { + createToolbarWithPlacements(kToolbarName, ["spacer"]); + ok(document.getElementById(kToolbarName), "Toolbar should be created."); + + // Check it's there with a generated ID: + assertAreaPlacements(kToolbarName, [/customizableui-special-spacer\d+/]); + let [spacerId] = getAreaWidgetIds(kToolbarName); + + CustomizableUI.addWidgetToArea("spacer", kToolbarName); + assertAreaPlacements(kToolbarName, [ + spacerId, + /customizableui-special-spacer\d+/, + ]); + let [, spacer2Id] = getAreaWidgetIds(kToolbarName); + + isnot(spacerId, spacer2Id, "Spacer ids shouldn't be equal."); + + CustomizableUI.addWidgetToArea("downloads-button", kToolbarName, 1); + assertAreaPlacements(kToolbarName, [spacerId, "downloads-button", spacer2Id]); + await removeCustomToolbars(); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_876926_customize_mode_wrapping.js b/browser/components/customizableui/test/browser_876926_customize_mode_wrapping.js new file mode 100644 index 0000000000..cf73326e53 --- /dev/null +++ b/browser/components/customizableui/test/browser_876926_customize_mode_wrapping.js @@ -0,0 +1,295 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kXULWidgetId = "a-test-button"; // we'll create a button with this ID. +const kAPIWidgetId = "save-page-button"; +const kPanel = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL; +const kToolbar = CustomizableUI.AREA_NAVBAR; +const kVisiblePalette = "customization-palette"; + +function checkWrapper(id) { + is( + document.querySelectorAll("#wrapper-" + id).length, + 1, + "There should be exactly 1 wrapper for " + + id + + " in the customizing window." + ); +} + +async function ensureVisible(node) { + let isInPalette = node.parentNode.parentNode == gNavToolbox.palette; + if (isInPalette) { + node.scrollIntoView(); + } + let dwu = window.windowUtils; + await BrowserTestUtils.waitForCondition(() => { + let nodeBounds = dwu.getBoundsWithoutFlushing(node); + if (isInPalette) { + let paletteBounds = dwu.getBoundsWithoutFlushing(gNavToolbox.palette); + if ( + !( + nodeBounds.top >= paletteBounds.top && + nodeBounds.bottom <= paletteBounds.bottom + ) + ) { + return false; + } + } + return nodeBounds.height && nodeBounds.width; + }); +} + +var move = { + async drag(id, target) { + let targetNode = document.getElementById(target); + if (CustomizableUI.getCustomizationTarget(targetNode)) { + targetNode = CustomizableUI.getCustomizationTarget(targetNode); + } + let nodeToMove = document.getElementById(id); + await ensureVisible(nodeToMove); + + simulateItemDrag(nodeToMove, targetNode, "end"); + }, + async dragToItem(id, target) { + let targetNode = document.getElementById(target); + if (CustomizableUI.getCustomizationTarget(targetNode)) { + targetNode = CustomizableUI.getCustomizationTarget(targetNode); + } + let items = targetNode.querySelectorAll("toolbarpaletteitem"); + if (target == kPanel) { + targetNode = items[items.length - 1]; + } else { + targetNode = items[0]; + } + let nodeToMove = document.getElementById(id); + await ensureVisible(nodeToMove); + simulateItemDrag(nodeToMove, targetNode, "start"); + }, + API(id, target) { + if (target == kVisiblePalette) { + return CustomizableUI.removeWidgetFromArea(id); + } + return CustomizableUI.addWidgetToArea(id, target, null); + }, +}; + +function isLast(containerId, defaultPlacements, id) { + assertAreaPlacements(containerId, defaultPlacements.concat([id])); + let thisTarget = CustomizableUI.getCustomizationTarget( + document.getElementById(containerId) + ); + is( + thisTarget.lastElementChild.firstElementChild.id, + id, + "Widget " + id + " should be in " + containerId + " in customizing window." + ); + let otherTarget = CustomizableUI.getCustomizationTarget( + otherWin.document.getElementById(containerId) + ); + is( + otherTarget.lastElementChild.id, + id, + "Widget " + id + " should be in " + containerId + " in other window." + ); +} + +function getLastVisibleNodeInToolbar(containerId, win = window) { + let container = CustomizableUI.getCustomizationTarget( + win.document.getElementById(containerId) + ); + let rv = container.lastElementChild; + while (rv?.hidden || rv?.firstElementChild?.hidden) { + rv = rv.previousElementSibling; + } + return rv; +} + +function isLastVisibleInToolbar(containerId, defaultPlacements, id) { + let newPlacements; + for (let i = defaultPlacements.length - 1; i >= 0; i--) { + let el = document.getElementById(defaultPlacements[i]); + if (el && !el.hidden) { + newPlacements = [...defaultPlacements]; + newPlacements.splice(i + 1, 0, id); + break; + } + } + if (!newPlacements) { + assertAreaPlacements(containerId, defaultPlacements.concat([id])); + } else { + assertAreaPlacements(containerId, newPlacements); + } + is( + getLastVisibleNodeInToolbar(containerId).firstElementChild.id, + id, + "Widget " + id + " should be in " + containerId + " in customizing window." + ); + is( + getLastVisibleNodeInToolbar(containerId, otherWin).id, + id, + "Widget " + id + " should be in " + containerId + " in other window." + ); +} + +function isFirst(containerId, defaultPlacements, id) { + assertAreaPlacements(containerId, [id].concat(defaultPlacements)); + let thisTarget = CustomizableUI.getCustomizationTarget( + document.getElementById(containerId) + ); + is( + thisTarget.firstElementChild.firstElementChild.id, + id, + "Widget " + id + " should be in " + containerId + " in customizing window." + ); + let otherTarget = CustomizableUI.getCustomizationTarget( + otherWin.document.getElementById(containerId) + ); + is( + otherTarget.firstElementChild.id, + id, + "Widget " + id + " should be in " + containerId + " in other window." + ); +} + +async function checkToolbar(id, method) { + // Place at start of the toolbar: + let toolbarPlacements = getAreaWidgetIds(kToolbar); + await move[method](id, kToolbar); + if (method == "dragToItem") { + isFirst(kToolbar, toolbarPlacements, id); + } else if (method == "drag") { + isLastVisibleInToolbar(kToolbar, toolbarPlacements, id); + } else { + isLast(kToolbar, toolbarPlacements, id); + } + checkWrapper(id); +} + +async function checkPanel(id, method) { + let panelPlacements = getAreaWidgetIds(kPanel); + await move[method](id, kPanel); + let children = document + .getElementById(kPanel) + .querySelectorAll("toolbarpaletteitem"); + let otherChildren = otherWin.document.getElementById(kPanel).children; + let newPlacements = panelPlacements.concat([id]); + // Relative position of the new item from the end: + let position = -1; + // For the drag to item case, we drag to the last item, making the dragged item the + // penultimate item. We can't well use the first item because the panel has complicated + // rules about rearranging wide items (which, by default, the first two items are). + if (method == "dragToItem") { + newPlacements.pop(); + newPlacements.splice(panelPlacements.length - 1, 0, id); + position = -2; + } + assertAreaPlacements(kPanel, newPlacements); + is( + children[children.length + position].firstElementChild.id, + id, + "Widget " + id + " should be in " + kPanel + " in customizing window." + ); + is( + otherChildren[otherChildren.length + position].id, + id, + "Widget " + id + " should be in " + kPanel + " in other window." + ); + checkWrapper(id); +} + +async function checkPalette(id, method) { + // Move back to palette: + await move[method](id, kVisiblePalette); + ok(CustomizableUI.inDefaultState, "Should end in default state"); + let visibleChildren = gCustomizeMode.visiblePalette.children; + let expectedChild = + method == "dragToItem" + ? visibleChildren[0] + : visibleChildren[visibleChildren.length - 1]; + // Items dragged to the end of the palette should be the final item. That they're the penultimate + // item when dragged is tracked in bug 1395950. Once that's fixed, this hack can be removed. + if (method == "drag") { + expectedChild = expectedChild.previousElementSibling; + } + is( + expectedChild.firstElementChild.id, + id, + "Widget " + + id + + " was moved using " + + method + + " and should now be wrapped in palette in customizing window." + ); + if (id == kXULWidgetId) { + ok( + otherWin.gNavToolbox.palette.querySelector("#" + id), + "Widget " + id + " should be in invisible palette in other window." + ); + } + checkWrapper(id); +} + +// This test needs a XUL button that's in the palette by default. No such +// button currently exists, so we create a simple one. +function createXULButtonForWindow(win) { + createDummyXULButton(kXULWidgetId, "test-button", win); +} + +function removeXULButtonForWindow(win) { + win.gNavToolbox.palette.querySelector(`#${kXULWidgetId}`).remove(); +} + +var otherWin; + +// Moving widgets in two windows, one with customize mode and one without, should work. +add_task(async function MoveWidgetsInTwoWindows() { + CustomizableUI.createWidget({ + id: "cui-mode-wrapping-some-panel-item", + label: "Test panel wrapping", + }); + await startCustomizing(); + otherWin = await openAndLoadWindow(null, true); + await otherWin.PanelUI.ensureReady(); + // Create the XUL button to use in the test in both windows. + createXULButtonForWindow(window); + createXULButtonForWindow(otherWin); + ok(CustomizableUI.inDefaultState, "Should start in default state"); + + for (let widgetId of [kXULWidgetId, kAPIWidgetId]) { + for (let method of ["API", "drag", "dragToItem"]) { + info("Moving widget " + widgetId + " using " + method); + await checkToolbar(widgetId, method); + // We add an item to the panel because otherwise we can't test dragging + // to items that are already there. We remove it because + // 'checkPalette' checks that we leave the browser in the default state. + CustomizableUI.addWidgetToArea( + "cui-mode-wrapping-some-panel-item", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await checkPanel(widgetId, method); + CustomizableUI.removeWidgetFromArea("cui-mode-wrapping-some-panel-item"); + await checkPalette(widgetId, method); + CustomizableUI.addWidgetToArea( + "cui-mode-wrapping-some-panel-item", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await checkPanel(widgetId, method); + await checkToolbar(widgetId, method); + CustomizableUI.removeWidgetFromArea("cui-mode-wrapping-some-panel-item"); + await checkPalette(widgetId, method); + } + } + await promiseWindowClosed(otherWin); + otherWin = null; + await endCustomizing(); + removeXULButtonForWindow(window); +}); + +add_task(async function asyncCleanup() { + CustomizableUI.destroyWidget("cui-mode-wrapping-some-panel-item"); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_876944_customize_mode_create_destroy.js b/browser/components/customizableui/test/browser_876944_customize_mode_create_destroy.js new file mode 100644 index 0000000000..33eccccbbf --- /dev/null +++ b/browser/components/customizableui/test/browser_876944_customize_mode_create_destroy.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kTestWidget1 = "test-customize-mode-create-destroy1"; + +// Creating and destroying a widget should correctly wrap/unwrap stuff +add_task(async function testWrapUnwrap() { + await startCustomizing(); + CustomizableUI.createWidget({ + id: kTestWidget1, + label: "Pretty label", + tooltiptext: "Pretty tooltip", + }); + let elem = document.getElementById(kTestWidget1); + let wrapper = document.getElementById("wrapper-" + kTestWidget1); + ok(elem, "There should be an item"); + ok(wrapper, "There should be a wrapper"); + is( + wrapper.firstElementChild.id, + kTestWidget1, + "Wrapper should have test widget" + ); + is( + wrapper.parentNode.id, + "customization-palette", + "Wrapper should be in palette" + ); + CustomizableUI.destroyWidget(kTestWidget1); + wrapper = document.getElementById("wrapper-" + kTestWidget1); + ok(!wrapper, "There should be a wrapper"); + let item = document.getElementById(kTestWidget1); + ok(!item, "There should no longer be an item"); +}); + +add_task(async function asyncCleanup() { + await endCustomizing(); + try { + CustomizableUI.destroyWidget(kTestWidget1); + } catch (ex) {} + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_877006_missing_view.js b/browser/components/customizableui/test/browser_877006_missing_view.js new file mode 100644 index 0000000000..c01d2f7b35 --- /dev/null +++ b/browser/components/customizableui/test/browser_877006_missing_view.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Should be able to add broken view widget +add_task(function testAddbrokenViewWidget() { + const kWidgetId = "test-877006-broken-widget"; + let widgetSpec = { + id: kWidgetId, + type: "view", + viewId: "idontexist", + /* Empty handler so we try to attach it maybe? */ + onViewShowing() {}, + }; + + let noError = true; + try { + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_NAVBAR); + } catch (ex) { + console.error(ex); + noError = false; + } + ok( + noError, + "Should not throw an exception trying to add a broken view widget." + ); + + noError = true; + try { + CustomizableUI.destroyWidget(kWidgetId); + } catch (ex) { + console.error(ex); + noError = false; + } + ok( + noError, + "Should not throw an exception trying to remove the broken view widget." + ); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_877178_unregisterArea.js b/browser/components/customizableui/test/browser_877178_unregisterArea.js new file mode 100644 index 0000000000..7b171462ff --- /dev/null +++ b/browser/components/customizableui/test/browser_877178_unregisterArea.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +registerCleanupFunction(removeCustomToolbars); + +// Sanity checks +add_task(function sanityChecks() { + SimpleTest.doesThrow( + () => CustomizableUI.registerArea("@foo"), + "Registering areas with an invalid ID should throw." + ); + + SimpleTest.doesThrow( + () => CustomizableUI.registerArea([]), + "Registering areas with an invalid ID should throw." + ); + + SimpleTest.doesThrow( + () => CustomizableUI.unregisterArea("@foo"), + "Unregistering areas with an invalid ID should throw." + ); + + SimpleTest.doesThrow( + () => CustomizableUI.unregisterArea([]), + "Unregistering areas with an invalid ID should throw." + ); + + SimpleTest.doesThrow( + () => CustomizableUI.unregisterArea("unknown"), + "Unregistering an area that's not registered should throw." + ); +}); + +// Check areas are loaded with their default placements. +add_task(function checkLoadedAres() { + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state." + ); +}); + +// Check registering and unregistering a new area. +add_task(function checkRegisteringAndUnregistering() { + const kToolbarId = "test-registration-toolbar"; + const kButtonId = "test-registration-button"; + createDummyXULButton(kButtonId); + createToolbarWithPlacements(kToolbarId, ["spring", kButtonId, "spring"]); + assertAreaPlacements(kToolbarId, [ + /customizableui-special-spring\d+/, + kButtonId, + /customizableui-special-spring\d+/, + ]); + ok( + !CustomizableUI.inDefaultState, + "With a new toolbar it is no longer in a default state." + ); + removeCustomToolbars(); // Will call unregisterArea for us + ok( + CustomizableUI.inDefaultState, + "When the toolbar is unregistered, " + + "everything will return to the default state." + ); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_877447_skip_missing_ids.js b/browser/components/customizableui/test/browser_877447_skip_missing_ids.js new file mode 100644 index 0000000000..83e7edbba3 --- /dev/null +++ b/browser/components/customizableui/test/browser_877447_skip_missing_ids.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +registerCleanupFunction(removeCustomToolbars); + +add_task(function skipMissingIDS() { + const kButtonId = "look-at-me-disappear-button"; + CustomizableUI.reset(); + ok(CustomizableUI.inDefaultState, "Should be in the default state."); + let btn = createDummyXULButton(kButtonId, "Gone!"); + CustomizableUI.addWidgetToArea(kButtonId, CustomizableUI.AREA_NAVBAR); + ok( + !CustomizableUI.inDefaultState, + "Should no longer be in the default state." + ); + is( + btn.parentNode.parentNode.id, + CustomizableUI.AREA_NAVBAR, + "Button should be in navbar" + ); + btn.remove(); + is(btn.parentNode, null, "Button is no longer in the navbar"); + ok( + CustomizableUI.inDefaultState, + "Should be back in the default state, " + + "despite unknown button ID in placements." + ); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_878452_drag_to_panel.js b/browser/components/customizableui/test/browser_878452_drag_to_panel.js new file mode 100644 index 0000000000..284583c853 --- /dev/null +++ b/browser/components/customizableui/test/browser_878452_drag_to_panel.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +CustomizableUI.createWidget({ + id: "cui-panel-item-to-drag-to", + defaultArea: CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + label: "Item in panel to drag to", +}); + +// Dragging an item from the palette to another button in the panel should work. +add_task(async function () { + await startCustomizing(); + let btn = document.getElementById("new-window-button"); + let placements = getAreaWidgetIds(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL); + + let lastButtonIndex = placements.length - 1; + let lastButton = placements[lastButtonIndex]; + let placementsAfterInsert = placements + .slice(0, lastButtonIndex) + .concat(["new-window-button", lastButton]); + let lastButtonNode = document.getElementById(lastButton); + simulateItemDrag(btn, lastButtonNode, "start"); + assertAreaPlacements( + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + placementsAfterInsert + ); + ok(!CustomizableUI.inDefaultState, "Should no longer be in default state."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(btn, palette); + CustomizableUI.removeWidgetFromArea("cui-panel-item-to-drag-to"); + ok(CustomizableUI.inDefaultState, "Should be in default state again."); + await endCustomizing(); +}); + +// Dragging an item from the palette to the panel itself should also work. +add_task(async function () { + CustomizableUI.addWidgetToArea( + "cui-panel-item-to-drag-to", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await startCustomizing(); + let btn = document.getElementById("new-window-button"); + let panel = document.getElementById(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL); + let placements = getAreaWidgetIds(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL); + + let placementsAfterAppend = placements.concat(["new-window-button"]); + simulateItemDrag(btn, panel); + assertAreaPlacements( + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + placementsAfterAppend + ); + ok(!CustomizableUI.inDefaultState, "Should no longer be in default state."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(btn, palette); + CustomizableUI.removeWidgetFromArea("cui-panel-item-to-drag-to"); + ok(CustomizableUI.inDefaultState, "Should be in default state again."); + await endCustomizing(); +}); + +// Dragging an item from the palette to an empty panel should also work. +add_task(async function () { + let widgetIds = getAreaWidgetIds(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL); + while (widgetIds.length) { + CustomizableUI.removeWidgetFromArea(widgetIds.shift()); + } + await startCustomizing(); + let btn = document.getElementById("new-window-button"); + let panel = document.getElementById(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL); + + assertAreaPlacements(panel.id, []); + + let placementsAfterAppend = ["new-window-button"]; + simulateItemDrag(btn, panel); + assertAreaPlacements( + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + placementsAfterAppend + ); + ok(!CustomizableUI.inDefaultState, "Should no longer be in default state."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(btn, palette); + assertAreaPlacements(panel.id, []); + await endCustomizing(); +}); + +registerCleanupFunction(async function asyncCleanup() { + CustomizableUI.destroyWidget("cui-panel-item-to-drag-to"); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_884402_customize_from_overflow.js b/browser/components/customizableui/test/browser_884402_customize_from_overflow.js new file mode 100644 index 0000000000..e41758bdc7 --- /dev/null +++ b/browser/components/customizableui/test/browser_884402_customize_from_overflow.js @@ -0,0 +1,117 @@ +"use strict"; + +var overflowPanel = document.getElementById("widget-overflow"); + +var originalWindowWidth; +registerCleanupFunction(function () { + overflowPanel.removeAttribute("animate"); + window.resizeTo(originalWindowWidth, window.outerHeight); + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + return TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +// Right-click on an item within the overflow panel should +// show a context menu with options to move it. +add_task(async function () { + overflowPanel.setAttribute("animate", "false"); + let fxaButton = document.getElementById("fxa-toolbar-menu-button"); + if (BrowserTestUtils.isHidden(fxaButton)) { + // FxA button is likely hidden since the user is logged out. + let initialFxaStatus = document.documentElement.getAttribute("fxastatus"); + document.documentElement.setAttribute("fxastatus", "signed_in"); + registerCleanupFunction(() => + document.documentElement.setAttribute("fxastatus", initialFxaStatus) + ); + ok(BrowserTestUtils.isVisible(fxaButton), "FxA button is now visible"); + } + + originalWindowWidth = window.outerWidth; + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = promisePanelElementShown(window, overflowPanel); + chevron.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + ok(fxaButton, "fxa-toolbar-menu-button was found"); + is( + fxaButton.getAttribute("overflowedItem"), + "true", + "FxA button is overflowing" + ); + EventUtils.synthesizeMouse(fxaButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is( + overflowPanel.state, + "open", + "The widget overflow panel should still be open." + ); + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + let hiddenPromise = promisePanelElementHidden(window, overflowPanel); + let moveToPanel = contextMenu.querySelector(".customize-context-moveToPanel"); + if (moveToPanel) { + contextMenu.activateItem(moveToPanel); + } else { + contextMenu.hidePopup(); + } + await hiddenContextPromise; + await hiddenPromise; + + let fxaButtonPlacement = CustomizableUI.getPlacementOfWidget( + "fxa-toolbar-menu-button" + ); + ok(fxaButtonPlacement, "FxA button should still have a placement"); + is( + fxaButtonPlacement && fxaButtonPlacement.area, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + "FxA button should be pinned now" + ); + CustomizableUI.reset(); + + // In some cases, it can take a tick for the navbar to overflow again. Wait for it: + await TestUtils.waitForCondition(() => + fxaButton.hasAttribute("overflowedItem") + ); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + + fxaButtonPlacement = CustomizableUI.getPlacementOfWidget( + "fxa-toolbar-menu-button" + ); + ok(fxaButtonPlacement, "FxA button should still have a placement"); + is( + fxaButtonPlacement && fxaButtonPlacement.area, + "nav-bar", + "FxA button should be back in the navbar now" + ); + + is( + fxaButton.getAttribute("overflowedItem"), + "true", + "FxA button should still be overflowed" + ); +}); diff --git a/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js b/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js new file mode 100644 index 0000000000..346608dc99 --- /dev/null +++ b/browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +function isFullscreenSizeMode() { + let sizemode = document.documentElement.getAttribute("sizemode"); + return sizemode == "fullscreen"; +} + +// Observers should be disabled when in customization mode. +add_task(async function () { + CustomizableUI.addWidgetToArea( + "fullscreen-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // Show the panel so it isn't hidden and has bindings applied etc.: + await document.getElementById("nav-bar").overflowable.show(); + + // Hide it again. + document.getElementById("widget-overflow").hidePopup(); + + let fullscreenButton = document.getElementById("fullscreen-button"); + ok( + !fullscreenButton.checked, + "Fullscreen button should not be checked when not in fullscreen." + ); + ok( + !isFullscreenSizeMode(), + "Should not be in fullscreen sizemode before we enter fullscreen." + ); + + BrowserFullScreen(); + await TestUtils.waitForCondition(() => isFullscreenSizeMode()); + ok( + fullscreenButton.checked, + "Fullscreen button should be checked when in fullscreen." + ); + + await startCustomizing(); + + let fullscreenButtonWrapper = document.getElementById( + "wrapper-fullscreen-button" + ); + ok( + fullscreenButtonWrapper.hasAttribute("itemobserves"), + "Observer should be moved to wrapper" + ); + fullscreenButton = document.getElementById("fullscreen-button"); + ok( + !fullscreenButton.hasAttribute("observes"), + "Observer should be removed from button" + ); + ok( + !fullscreenButton.checked, + "Fullscreen button should no longer be checked during customization mode" + ); + + await endCustomizing(); + + BrowserFullScreen(); + fullscreenButton = document.getElementById("fullscreen-button"); + await TestUtils.waitForCondition(() => !isFullscreenSizeMode()); + ok( + !fullscreenButton.checked, + "Fullscreen button should not be checked when not in fullscreen." + ); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_885530_showInPrivateBrowsing.js b/browser/components/customizableui/test/browser_885530_showInPrivateBrowsing.js new file mode 100644 index 0000000000..e1f763e2eb --- /dev/null +++ b/browser/components/customizableui/test/browser_885530_showInPrivateBrowsing.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kWidgetId = "some-widget"; + +function assertWidgetExists(aWindow, aExists) { + if (aExists) { + ok( + aWindow.document.getElementById(kWidgetId), + "Should have found test widget in the window" + ); + } else { + is( + aWindow.document.getElementById(kWidgetId), + null, + "Should not have found test widget in the window" + ); + } +} + +// A widget that is created with showInPrivateBrowsing undefined should +// have that value default to true. +add_task(function () { + let wrapper = CustomizableUI.createWidget({ + id: kWidgetId, + }); + ok( + wrapper.showInPrivateBrowsing, + "showInPrivateBrowsing should have defaulted to true." + ); + CustomizableUI.destroyWidget(kWidgetId); +}); + +// Add a widget via the API with showInPrivateBrowsing set to false +// and ensure it does not appear in pre-existing or newly created +// private windows. +add_task(async function () { + let plain1 = await openAndLoadWindow(); + let private1 = await openAndLoadWindow({ private: true }); + CustomizableUI.createWidget({ + id: kWidgetId, + removable: true, + showInPrivateBrowsing: false, + }); + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_NAVBAR); + assertWidgetExists(plain1, true); + assertWidgetExists(private1, false); + + // Now open up some new windows. The widget should exist in the new + // plain window, but not the new private window. + let plain2 = await openAndLoadWindow(); + let private2 = await openAndLoadWindow({ private: true }); + assertWidgetExists(plain2, true); + assertWidgetExists(private2, false); + + // Try moving the widget around and make sure it doesn't get added + // to the private windows. We'll start by appending it to the tabstrip. + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_TABSTRIP); + assertWidgetExists(plain1, true); + assertWidgetExists(plain2, true); + assertWidgetExists(private1, false); + assertWidgetExists(private2, false); + + // And then move it to the beginning of the tabstrip. + CustomizableUI.moveWidgetWithinArea(kWidgetId, 0); + assertWidgetExists(plain1, true); + assertWidgetExists(plain2, true); + assertWidgetExists(private1, false); + assertWidgetExists(private2, false); + + CustomizableUI.removeWidgetFromArea("some-widget"); + assertWidgetExists(plain1, false); + assertWidgetExists(plain2, false); + assertWidgetExists(private1, false); + assertWidgetExists(private2, false); + + await Promise.all( + [plain1, plain2, private1, private2].map(promiseWindowClosed) + ); + + CustomizableUI.destroyWidget("some-widget"); +}); + +// Add a widget via the API with showInPrivateBrowsing set to true, +// and ensure that it appears in pre-existing or newly created +// private browsing windows. +add_task(async function () { + let plain1 = await openAndLoadWindow(); + let private1 = await openAndLoadWindow({ private: true }); + + CustomizableUI.createWidget({ + id: kWidgetId, + removable: true, + showInPrivateBrowsing: true, + }); + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_NAVBAR); + assertWidgetExists(plain1, true); + assertWidgetExists(private1, true); + + // Now open up some new windows. The widget should exist in the new + // plain window, but not the new private window. + let plain2 = await openAndLoadWindow(); + let private2 = await openAndLoadWindow({ private: true }); + + assertWidgetExists(plain2, true); + assertWidgetExists(private2, true); + + // Try moving the widget around and make sure it doesn't get added + // to the private windows. We'll start by appending it to the tabstrip. + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_TABSTRIP); + assertWidgetExists(plain1, true); + assertWidgetExists(plain2, true); + assertWidgetExists(private1, true); + assertWidgetExists(private2, true); + + // And then move it to the beginning of the tabstrip. + CustomizableUI.moveWidgetWithinArea(kWidgetId, 0); + assertWidgetExists(plain1, true); + assertWidgetExists(plain2, true); + assertWidgetExists(private1, true); + assertWidgetExists(private2, true); + + CustomizableUI.removeWidgetFromArea("some-widget"); + assertWidgetExists(plain1, false); + assertWidgetExists(plain2, false); + assertWidgetExists(private1, false); + assertWidgetExists(private2, false); + + await Promise.all( + [plain1, plain2, private1, private2].map(promiseWindowClosed) + ); + + CustomizableUI.destroyWidget("some-widget"); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_886323_buildArea_removable_nodes.js b/browser/components/customizableui/test/browser_886323_buildArea_removable_nodes.js new file mode 100644 index 0000000000..99968a8266 --- /dev/null +++ b/browser/components/customizableui/test/browser_886323_buildArea_removable_nodes.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kButtonId = "test-886323-removable-moved-node"; +const kLazyAreaId = "test-886323-lazy-area-for-removability-testing"; + +var gNavBar = document.getElementById(CustomizableUI.AREA_NAVBAR); +var gLazyArea; + +// Removable nodes shouldn't be moved by buildArea +add_task(async function () { + let dummyBtn = createDummyXULButton(kButtonId, "Dummy"); + dummyBtn.setAttribute("removable", "true"); + CustomizableUI.getCustomizationTarget(gNavBar).appendChild(dummyBtn); + let popupSet = document.getElementById("mainPopupSet"); + gLazyArea = document.createXULElement("panel"); + gLazyArea.id = kLazyAreaId; + gLazyArea.hidden = true; + popupSet.appendChild(gLazyArea); + CustomizableUI.registerArea(kLazyAreaId, { + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + }); + CustomizableUI.addWidgetToArea(kButtonId, kLazyAreaId); + assertAreaPlacements( + kLazyAreaId, + [kButtonId], + "Placements should have changed because widget is removable." + ); + let btn = document.getElementById(kButtonId); + btn.setAttribute("removable", "false"); + gLazyArea._customizationTarget = gLazyArea; + CustomizableUI.registerToolbarNode(gLazyArea, []); + assertAreaPlacements( + kLazyAreaId, + [], + "Placements should no longer include widget." + ); + is( + btn.parentNode.id, + CustomizableUI.getCustomizationTarget(gNavBar).id, + "Button shouldn't actually have moved as it's not removable" + ); + btn = document.getElementById(kButtonId); + if (btn) { + btn.remove(); + } + CustomizableUI.removeWidgetFromArea(kButtonId); + CustomizableUI.unregisterArea(kLazyAreaId); + gLazyArea.remove(); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_890262_destroyWidget_after_add_to_panel.js b/browser/components/customizableui/test/browser_890262_destroyWidget_after_add_to_panel.js new file mode 100644 index 0000000000..61e354dd61 --- /dev/null +++ b/browser/components/customizableui/test/browser_890262_destroyWidget_after_add_to_panel.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kLazyAreaId = "test-890262-lazy-area"; +const kWidget1Id = "test-890262-widget1"; +const kWidget2Id = "test-890262-widget2"; + +setupArea(); + +// Destroying a widget after defaulting it to a lazy area should work. +add_task(function () { + CustomizableUI.createWidget({ + id: kWidget1Id, + removable: true, + defaultArea: kLazyAreaId, + }); + let noError = true; + try { + CustomizableUI.destroyWidget(kWidget1Id); + } catch (ex) { + console.error(ex); + noError = false; + } + ok( + noError, + "Shouldn't throw an exception for a widget that was created in a not-yet-constructed area" + ); +}); + +// Destroying a widget after moving it to a lazy area should work. +add_task(function () { + CustomizableUI.createWidget({ + id: kWidget2Id, + removable: true, + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + CustomizableUI.addWidgetToArea(kWidget2Id, kLazyAreaId); + let noError = true; + try { + CustomizableUI.destroyWidget(kWidget2Id); + } catch (ex) { + console.error(ex); + noError = false; + } + ok( + noError, + "Shouldn't throw an exception for a widget that was added to a not-yet-constructed area" + ); +}); + +add_task(async function asyncCleanup() { + let lazyArea = document.getElementById(kLazyAreaId); + if (lazyArea) { + lazyArea.remove(); + } + try { + CustomizableUI.unregisterArea(kLazyAreaId); + } catch (ex) {} // If we didn't register successfully for some reason + await resetCustomization(); +}); + +function setupArea() { + let lazyArea = document.createXULElement("hbox"); + lazyArea.id = kLazyAreaId; + document.getElementById("nav-bar").appendChild(lazyArea); + CustomizableUI.registerArea(kLazyAreaId, { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: [], + }); +} diff --git a/browser/components/customizableui/test/browser_892955_isWidgetRemovable_for_removed_widgets.js b/browser/components/customizableui/test/browser_892955_isWidgetRemovable_for_removed_widgets.js new file mode 100644 index 0000000000..7584c52bb6 --- /dev/null +++ b/browser/components/customizableui/test/browser_892955_isWidgetRemovable_for_removed_widgets.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kWidgetId = "test-892955-remove-widget"; + +// Removing a destroyed widget should work. +add_task(async function () { + let widgetSpec = { + id: kWidgetId, + defaultArea: CustomizableUI.AREA_NAVBAR, + }; + + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.destroyWidget(kWidgetId); + let noError = true; + try { + CustomizableUI.removeWidgetFromArea(kWidgetId); + } catch (ex) { + noError = false; + console.error(ex); + } + ok(noError, "Shouldn't throw an error removing a destroyed widget."); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_892956_destroyWidget_defaultPlacements.js b/browser/components/customizableui/test/browser_892956_destroyWidget_defaultPlacements.js new file mode 100644 index 0000000000..7ad68e16d0 --- /dev/null +++ b/browser/components/customizableui/test/browser_892956_destroyWidget_defaultPlacements.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kWidgetId = "test-892956-destroyWidget-defaultPlacement"; + +// destroyWidget should clean up defaultPlacements if the widget had a defaultArea +add_task(async function () { + ok( + CustomizableUI.inDefaultState, + "Should be in the default state when we start" + ); + + let widgetSpec = { + id: kWidgetId, + defaultArea: CustomizableUI.AREA_NAVBAR, + }; + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.destroyWidget(kWidgetId); + ok( + CustomizableUI.inDefaultState, + "Should be in the default state when we finish" + ); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_901207_searchbar_in_panel.js b/browser/components/customizableui/test/browser_901207_searchbar_in_panel.js new file mode 100644 index 0000000000..1576e10cec --- /dev/null +++ b/browser/components/customizableui/test/browser_901207_searchbar_in_panel.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +logActiveElement(); + +async function waitForSearchBarFocus() { + let searchbar = document.getElementById("searchbar"); + await TestUtils.waitForCondition(function () { + logActiveElement(); + return document.activeElement === searchbar.textbox; + }); +} + +// Ctrl+K should open the menu panel and focus the search bar if the search bar is in the panel. +add_task(async function check_shortcut_when_in_closed_overflow_panel_closed() { + CustomizableUI.addWidgetToArea( + "search-container", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + let shownPanelPromise = promiseOverflowShown(window); + sendWebSearchKeyCommand(); + await shownPanelPromise; + + await waitForSearchBarFocus(); + + let hiddenPanelPromise = promiseOverflowHidden(window); + EventUtils.synthesizeKey("KEY_Escape"); + await hiddenPanelPromise; + CustomizableUI.reset(); +}); + +// Ctrl+K should give focus to the searchbar when the searchbar is in the menupanel and the panel is already opened. +add_task(async function check_shortcut_when_in_opened_overflow_panel() { + CustomizableUI.addWidgetToArea( + "search-container", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await document.getElementById("nav-bar").overflowable.show(); + + sendWebSearchKeyCommand(); + + await waitForSearchBarFocus(); + + let hiddenPanelPromise = promiseOverflowHidden(window); + EventUtils.synthesizeKey("KEY_Escape"); + await hiddenPanelPromise; + CustomizableUI.reset(); +}); + +// Ctrl+K should open the overflow panel and focus the search bar if the search bar is overflowed. +add_task(async function check_shortcut_when_in_overflow() { + this.originalWindowWidth = window.outerWidth; + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + ok(CustomizableUI.inDefaultState, "Should start in default state."); + + Services.prefs.setBoolPref("browser.search.widget.inNavBar", true); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => { + return ( + navbar.getAttribute("overflowing") == "true" && + !navbar.querySelector("#search-container") + ); + }); + ok( + !navbar.querySelector("#search-container"), + "Search container should be overflowing" + ); + + let shownPanelPromise = promiseOverflowShown(window); + sendWebSearchKeyCommand(); + await shownPanelPromise; + + let chevron = document.getElementById("nav-bar-overflow-button"); + await TestUtils.waitForCondition(() => chevron.open); + + await waitForSearchBarFocus(); + + let hiddenPanelPromise = promiseOverflowHidden(window); + EventUtils.synthesizeKey("KEY_Escape"); + await hiddenPanelPromise; + + Services.prefs.setBoolPref("browser.search.widget.inNavBar", false); + + navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + window.resizeTo(this.originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should not have an overflowing toolbar." + ); +}); + +// Ctrl+K should focus the search bar if it is in the navbar and not overflowing. +add_task(async function check_shortcut_when_not_in_overflow() { + Services.prefs.setBoolPref("browser.search.widget.inNavBar", true); + let placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement.area, CustomizableUI.AREA_NAVBAR, "Should be in nav-bar"); + + sendWebSearchKeyCommand(); + + // This fails if the screen resolution is small and the search bar overflows + // from the nav bar even with the original window width. + await waitForSearchBarFocus(); + + Services.prefs.setBoolPref("browser.search.widget.inNavBar", false); +}); + +function sendWebSearchKeyCommand() { + document.documentElement.focus(); + EventUtils.synthesizeKey("k", { accelKey: true }); +} + +function logActiveElement() { + let element = document.activeElement; + let str = ""; + while (element && element.parentNode) { + str = + " (" + + element.localName + + "#" + + element.id + + "." + + [...element.classList].join(".") + + ") >" + + str; + element = element.parentNode; + } + info("Active element: " + element ? str : "null"); +} diff --git a/browser/components/customizableui/test/browser_909779_overflow_toolbars_new_window.js b/browser/components/customizableui/test/browser_909779_overflow_toolbars_new_window.js new file mode 100644 index 0000000000..201a974f2b --- /dev/null +++ b/browser/components/customizableui/test/browser_909779_overflow_toolbars_new_window.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Resize to a small window, open a new window, check that new window handles overflow properly +add_task(async function () { + let originalWindowWidth = window.outerWidth; + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + let oldChildCount = + CustomizableUI.getCustomizationTarget(navbar).childElementCount; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + + Assert.less( + CustomizableUI.getCustomizationTarget(navbar).childElementCount, + oldChildCount, + "Should have fewer children." + ); + let newWindow = await openAndLoadWindow(); + let otherNavBar = newWindow.document.getElementById( + CustomizableUI.AREA_NAVBAR + ); + await TestUtils.waitForCondition(() => + otherNavBar.hasAttribute("overflowing") + ); + ok( + otherNavBar.hasAttribute("overflowing"), + "Other window should have an overflowing toolbar." + ); + await promiseWindowClosed(newWindow); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should no longer have an overflowing toolbar." + ); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_913972_currentset_overflow.js b/browser/components/customizableui/test/browser_913972_currentset_overflow.js new file mode 100644 index 0000000000..36556ac9da --- /dev/null +++ b/browser/components/customizableui/test/browser_913972_currentset_overflow.js @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + +registerCleanupFunction(async function asyncCleanup() { + await resetCustomization(); +}); + +// Resize to a small window, resize back, shouldn't affect default state. +add_task(async function () { + let originalWindowWidth = window.outerWidth; + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + ok(CustomizableUI.inDefaultState, "Should start in default state."); + let navbarTarget = CustomizableUI.getCustomizationTarget(navbar); + let oldChildCount = navbarTarget.childElementCount; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => navbar.hasAttribute("overflowing"), + "Navbar has a overflowing attribute" + ); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + ok( + CustomizableUI.inDefaultState, + "Should still be in default state when overflowing." + ); + Assert.less( + navbarTarget.childElementCount, + oldChildCount, + "Should have fewer children." + ); + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !navbar.hasAttribute("overflowing"), + "Navbar does not have an overflowing attribute" + ); + ok( + !navbar.hasAttribute("overflowing"), + "Should no longer have an overflowing toolbar." + ); + ok( + CustomizableUI.inDefaultState, + "Should still be in default state now we're no longer overflowing." + ); + + // Verify actual physical placements match those of the placement array: + let placementCounter = 0; + let placements = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_NAVBAR + ); + for (let node of navbarTarget.children) { + if (node.getAttribute("skipintoolbarset") == "true") { + continue; + } + is( + placements[placementCounter++], + node.id, + "Nodes should match after overflow" + ); + } + is( + placements.length, + placementCounter, + "Should have as many nodes as expected" + ); + is( + navbarTarget.childElementCount, + oldChildCount, + "Number of nodes should match" + ); +}); + +// Enter and exit customization mode, check that default state is correct. +add_task(async function () { + ok(CustomizableUI.inDefaultState, "Should start in default state."); + await startCustomizing(); + ok( + CustomizableUI.inDefaultState, + "Should be in default state in customization mode." + ); + await endCustomizing(); + ok( + CustomizableUI.inDefaultState, + "Should be in default state after customization mode." + ); +}); diff --git a/browser/components/customizableui/test/browser_914138_widget_API_overflowable_toolbar.js b/browser/components/customizableui/test/browser_914138_widget_API_overflowable_toolbar.js new file mode 100644 index 0000000000..1d57e3d1fb --- /dev/null +++ b/browser/components/customizableui/test/browser_914138_widget_API_overflowable_toolbar.js @@ -0,0 +1,347 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); +var overflowList = document.getElementById( + navbar.getAttribute("default-overflowtarget") +); + +const kTestBtn1 = "test-addWidgetToArea-overflow"; +const kTestBtn2 = "test-removeWidgetFromArea-overflow"; +const kTestBtn3 = "test-createWidget-overflow"; +const kTestBtn4 = "test-createWidget-overflow-first-item"; +const kTestBtn5 = "test-addWidgetToArea-overflow-first-item"; +const kSidebarBtn = "sidebar-button"; +const kLibraryButton = "library-button"; +const kDownloadsBtn = "downloads-button"; +const kSearchBox = "search-container"; + +var originalWindowWidth; + +// Adding a widget should add it next to the widget it's being inserted next to. +add_task(async function subsequent_widget() { + originalWindowWidth = window.outerWidth; + createDummyXULButton(kTestBtn1, "Test"); + ok( + !navbar.hasAttribute("overflowing"), + "Should start subsequent_widget with a non-overflowing toolbar." + ); + ok( + CustomizableUI.inDefaultState, + "Should start subsequent_widget in default state." + ); + CustomizableUI.addWidgetToArea(kSidebarBtn, "nav-bar"); + await waitForElementShown(document.getElementById(kSidebarBtn)); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => { + return ( + navbar.hasAttribute("overflowing") && + document.getElementById(kSidebarBtn).getAttribute("overflowedItem") == + "true" + ); + }); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + ok( + !navbar.querySelector("#" + kSidebarBtn), + "Sidebar button should no longer be in the navbar" + ); + let sidebarBtnNode = overflowList.querySelector("#" + kSidebarBtn); + ok(sidebarBtnNode, "Sidebar button should be overflowing"); + ok( + sidebarBtnNode && sidebarBtnNode.getAttribute("overflowedItem") == "true", + "Sidebar button should have overflowedItem attribute" + ); + + let placementOfSidebarButton = CustomizableUI.getWidgetIdsInArea( + navbar.id + ).indexOf(kSidebarBtn); + CustomizableUI.addWidgetToArea( + kTestBtn1, + navbar.id, + placementOfSidebarButton + ); + ok( + !navbar.querySelector("#" + kTestBtn1), + "New button should not be in the navbar" + ); + let newButtonNode = overflowList.querySelector("#" + kTestBtn1); + ok(newButtonNode, "New button should be overflowing"); + ok( + newButtonNode && newButtonNode.getAttribute("overflowedItem") == "true", + "New button should have overflowedItem attribute" + ); + let nextEl = newButtonNode && newButtonNode.nextElementSibling; + is( + nextEl && nextEl.id, + kSidebarBtn, + "Test button should be next to sidebar button." + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should not have an overflowing toolbar." + ); + ok( + navbar.querySelector("#" + kSidebarBtn), + "Sidebar button should be in the navbar" + ); + ok( + sidebarBtnNode && sidebarBtnNode.getAttribute("overflowedItem") != "true", + "Sidebar button should no longer have overflowedItem attribute" + ); + ok( + !overflowList.querySelector("#" + kSidebarBtn), + "Sidebar button should no longer be overflowing" + ); + ok( + navbar.querySelector("#" + kTestBtn1), + "Test button should be in the navbar" + ); + ok( + !overflowList.querySelector("#" + kTestBtn1), + "Test button should no longer be overflowing" + ); + ok( + newButtonNode && newButtonNode.getAttribute("overflowedItem") != "true", + "New button should no longer have overflowedItem attribute" + ); + let el = document.getElementById(kTestBtn1); + if (el) { + CustomizableUI.removeWidgetFromArea(kTestBtn1); + el.remove(); + } + CustomizableUI.removeWidgetFromArea(kSidebarBtn); + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +// Removing a widget should remove it from the overflow list if that is where it is, and update it accordingly. +add_task(async function remove_widget() { + createDummyXULButton(kTestBtn2, "Test"); + ok( + !navbar.hasAttribute("overflowing"), + "Should start remove_widget with a non-overflowing toolbar." + ); + ok( + CustomizableUI.inDefaultState, + "Should start remove_widget in default state." + ); + CustomizableUI.addWidgetToArea(kTestBtn2, navbar.id); + ok( + !navbar.hasAttribute("overflowing"), + "Should still have a non-overflowing toolbar." + ); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + ok( + !navbar.querySelector("#" + kTestBtn2), + "Test button should not be in the navbar" + ); + ok( + overflowList.querySelector("#" + kTestBtn2), + "Test button should be overflowing" + ); + + CustomizableUI.removeWidgetFromArea(kTestBtn2); + + ok( + !overflowList.querySelector("#" + kTestBtn2), + "Test button should not be overflowing." + ); + ok( + !navbar.querySelector("#" + kTestBtn2), + "Test button should not be in the navbar" + ); + ok( + gNavToolbox.palette.querySelector("#" + kTestBtn2), + "Test button should be in the palette" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should not have an overflowing toolbar." + ); + let el = document.getElementById(kTestBtn2); + if (el) { + CustomizableUI.removeWidgetFromArea(kTestBtn2); + el.remove(); + } + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +// Constructing a widget while overflown should set the right class on it. +add_task(async function construct_widget() { + originalWindowWidth = window.outerWidth; + ok( + !navbar.hasAttribute("overflowing"), + "Should start construct_widget with a non-overflowing toolbar." + ); + ok( + CustomizableUI.inDefaultState, + "Should start construct_widget in default state." + ); + + CustomizableUI.addWidgetToArea(kSidebarBtn, "nav-bar"); + await waitForElementShown(document.getElementById(kSidebarBtn)); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => { + return ( + navbar.hasAttribute("overflowing") && + document.getElementById(kSidebarBtn).getAttribute("overflowedItem") == + "true" + ); + }); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + ok( + !navbar.querySelector("#" + kSidebarBtn), + "Sidebar button should no longer be in the navbar" + ); + let sidebarBtnNode = overflowList.querySelector("#" + kSidebarBtn); + ok(sidebarBtnNode, "Sidebar button should be overflowing"); + ok( + sidebarBtnNode && sidebarBtnNode.getAttribute("overflowedItem") == "true", + "Sidebar button should have overflowedItem class" + ); + + let testBtnSpec = { + id: kTestBtn3, + label: "Overflowable widget test", + defaultArea: "nav-bar", + }; + CustomizableUI.createWidget(testBtnSpec); + let testNode = overflowList.querySelector("#" + kTestBtn3); + ok(testNode, "Test button should be overflowing"); + ok( + testNode && testNode.getAttribute("overflowedItem") == "true", + "Test button should have overflowedItem class" + ); + + CustomizableUI.destroyWidget(kTestBtn3); + testNode = document.getElementById(kTestBtn3); + ok(!testNode, "Test button should be gone"); + + CustomizableUI.createWidget(testBtnSpec); + testNode = overflowList.querySelector("#" + kTestBtn3); + ok(testNode, "Test button should be overflowing"); + ok( + testNode && testNode.getAttribute("overflowedItem") == "true", + "Test button should have overflowedItem class" + ); + + CustomizableUI.removeWidgetFromArea(kTestBtn3); + testNode = document.getElementById(kTestBtn3); + ok(!testNode, "Test button should be gone"); + CustomizableUI.destroyWidget(kTestBtn3); + CustomizableUI.removeWidgetFromArea(kSidebarBtn); + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +add_task(async function insertBeforeFirstItemInOverflow() { + originalWindowWidth = window.outerWidth; + + ok( + !navbar.hasAttribute("overflowing"), + "Should start insertBeforeFirstItemInOverflow with a non-overflowing toolbar." + ); + ok( + CustomizableUI.inDefaultState, + "Should start insertBeforeFirstItemInOverflow in default state." + ); + + CustomizableUI.addWidgetToArea( + kLibraryButton, + "nav-bar", + CustomizableUI.getWidgetIdsInArea("nav-bar").indexOf( + "save-to-pocket-button" + ) + ); + let libraryButton = document.getElementById(kLibraryButton); + await waitForElementShown(libraryButton); + // Ensure nothing flexes to make the resize predictable: + navbar + .querySelectorAll("toolbarspring") + .forEach(s => CustomizableUI.removeWidgetFromArea(s.id)); + let urlbar = document.getElementById("urlbar-container"); + urlbar.style.minWidth = urlbar.getBoundingClientRect().width + "px"; + // Negative number to make the window smaller by the difference between the left side of + // the item next to the library button and left side of the hamburger one. + // The width of the overflow button that needs to appear will then be enough to + // also hide the library button. + let resizeWidthToMakeLibraryLast = + libraryButton.nextElementSibling.getBoundingClientRect().left - + PanelUI.menuButton.parentNode.getBoundingClientRect().left + + 10; // Leave some margin for the margins between buttons etc.; + info( + "Resizing to " + + resizeWidthToMakeLibraryLast + + " , waiting for library to overflow." + ); + window.resizeBy(resizeWidthToMakeLibraryLast, 0); + await TestUtils.waitForCondition(() => { + return ( + libraryButton.getAttribute("overflowedItem") == "true" && + !libraryButton.previousElementSibling + ); + }); + + let testBtnSpec = { id: kTestBtn4, label: "Overflowable widget test" }; + let placementOfLibraryButton = CustomizableUI.getWidgetIdsInArea( + navbar.id + ).indexOf(kLibraryButton); + CustomizableUI.createWidget(testBtnSpec); + CustomizableUI.addWidgetToArea( + kTestBtn4, + "nav-bar", + placementOfLibraryButton + ); + let testNode = overflowList.querySelector("#" + kTestBtn4); + ok(testNode, "Test button should be overflowing"); + ok( + testNode && testNode.getAttribute("overflowedItem") == "true", + "Test button should have overflowedItem class" + ); + CustomizableUI.destroyWidget(kTestBtn4); + testNode = document.getElementById(kTestBtn4); + ok(!testNode, "Test button should be gone"); + + createDummyXULButton(kTestBtn5, "Test"); + CustomizableUI.addWidgetToArea( + kTestBtn5, + "nav-bar", + placementOfLibraryButton + ); + testNode = overflowList.querySelector("#" + kTestBtn5); + ok(testNode, "Test button should be overflowing"); + ok( + testNode && testNode.getAttribute("overflowedItem") == "true", + "Test button should have overflowedItem class" + ); + CustomizableUI.removeWidgetFromArea(kTestBtn5); + testNode && testNode.remove(); + + urlbar.style.removeProperty("min-width"); + CustomizableUI.removeWidgetFromArea(kLibraryButton); + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + await resetCustomization(); +}); + +registerCleanupFunction(async function asyncCleanup() { + document.getElementById("urlbar-container").style.removeProperty("min-width"); + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_918049_skipintoolbarset_dnd.js b/browser/components/customizableui/test/browser_918049_skipintoolbarset_dnd.js new file mode 100644 index 0000000000..bd0a7d5795 --- /dev/null +++ b/browser/components/customizableui/test/browser_918049_skipintoolbarset_dnd.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var navbar; +var skippedItem; + +// Attempting to drag a skipintoolbarset item should work. +add_task(async function () { + navbar = document.getElementById("nav-bar"); + skippedItem = document.createXULElement("toolbarbutton"); + skippedItem.id = "test-skipintoolbarset-item"; + skippedItem.setAttribute("label", "Test"); + skippedItem.setAttribute("skipintoolbarset", "true"); + skippedItem.setAttribute("removable", "true"); + CustomizableUI.getCustomizationTarget(navbar).appendChild(skippedItem); + let stopReloadButton = document.getElementById("stop-reload-button"); + await startCustomizing(); + await waitForElementShown(skippedItem); + ok(CustomizableUI.inDefaultState, "Should still be in default state"); + simulateItemDrag(skippedItem, stopReloadButton, "start", 0); + ok(CustomizableUI.inDefaultState, "Should still be in default state"); + let skippedItemWrapper = skippedItem.parentNode; + is( + skippedItemWrapper.nextElementSibling && + skippedItemWrapper.nextElementSibling.id, + stopReloadButton.parentNode.id, + "Should be next to stop/reload button" + ); + simulateItemDrag(stopReloadButton, skippedItem, "start", 0); + let wrapper = stopReloadButton.parentNode; + is( + wrapper.nextElementSibling && wrapper.nextElementSibling.id, + skippedItem.parentNode.id, + "Should be next to skipintoolbarset item" + ); + ok(CustomizableUI.inDefaultState, "Should still be in default state"); +}); + +add_task(async function asyncCleanup() { + await endCustomizing(); + skippedItem.remove(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_923857_customize_mode_event_wrapping_during_reset.js b/browser/components/customizableui/test/browser_923857_customize_mode_event_wrapping_during_reset.js new file mode 100644 index 0000000000..d416f34144 --- /dev/null +++ b/browser/components/customizableui/test/browser_923857_customize_mode_event_wrapping_during_reset.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Customize mode reset button should revert correctly +add_task(async function () { + await startCustomizing(); + let devButton = document.getElementById("developer-button"); + let fxaButton = document.getElementById("fxa-toolbar-menu-button"); + let stopReloadButton = document.getElementById("stop-reload-button"); + let palette = document.getElementById("customization-palette"); + ok( + devButton && fxaButton && stopReloadButton && palette, + "Stuff should exist" + ); + simulateItemDrag(devButton, fxaButton); + simulateItemDrag(stopReloadButton, palette); + await gCustomizeMode.reset(); + ok(CustomizableUI.inDefaultState, "Should be back in default state"); + await endCustomizing(); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_927717_customize_drag_empty_toolbar.js b/browser/components/customizableui/test/browser_927717_customize_drag_empty_toolbar.js new file mode 100644 index 0000000000..340e840d83 --- /dev/null +++ b/browser/components/customizableui/test/browser_927717_customize_drag_empty_toolbar.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kTestToolbarId = "test-empty-drag"; + +// Attempting to drag an item to an empty container should work. +add_task(async function () { + await createToolbarWithPlacements(kTestToolbarId, []); + await startCustomizing(); + let libraryButton = document.getElementById("library-button"); + let customToolbar = document.getElementById(kTestToolbarId); + simulateItemDrag(libraryButton, customToolbar); + assertAreaPlacements(kTestToolbarId, ["library-button"]); + ok( + libraryButton.parentNode && + libraryButton.parentNode.parentNode == customToolbar, + "Button should really be in toolbar" + ); + await endCustomizing(); + removeCustomToolbars(); +}); + +add_task(async function asyncCleanup() { + await endCustomizing(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_934113_menubar_removable.js b/browser/components/customizableui/test/browser_934113_menubar_removable.js new file mode 100644 index 0000000000..8f41baba7a --- /dev/null +++ b/browser/components/customizableui/test/browser_934113_menubar_removable.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Attempting to drag the menubar to the navbar shouldn't work. +add_task(async function () { + await startCustomizing(); + let menuItems = document.getElementById("menubar-items"); + let navbar = document.getElementById("nav-bar"); + let menubar = document.getElementById("toolbar-menubar"); + // Force the menu to be shown. + const kAutohide = menubar.getAttribute("autohide"); + menubar.setAttribute("autohide", "false"); + simulateItemDrag(menuItems, CustomizableUI.getCustomizationTarget(navbar)); + + is( + getAreaWidgetIds("nav-bar").indexOf("menubar-items"), + -1, + "Menu bar shouldn't be in the navbar." + ); + ok( + !navbar.querySelector("#menubar-items"), + "Shouldn't find menubar items in the navbar." + ); + ok( + menubar.querySelector("#menubar-items"), + "Should find menubar items in the menubar." + ); + isnot( + getAreaWidgetIds("toolbar-menubar").indexOf("menubar-items"), + -1, + "Menubar items shouldn't be missing from the navbar." + ); + menubar.setAttribute("autohide", kAutohide); + await endCustomizing(); +}); + +add_task(async function asyncCleanup() { + await endCustomizing(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_934951_zoom_in_toolbar.js b/browser/components/customizableui/test/browser_934951_zoom_in_toolbar.js new file mode 100644 index 0000000000..db1d6175ab --- /dev/null +++ b/browser/components/customizableui/test/browser_934951_zoom_in_toolbar.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +let gZoomResetButton; + +async function waitForZoom(zoom) { + if (parseInt(gZoomResetButton.label) == zoom) { + return; + } + await promiseAttributeMutation(gZoomResetButton, "label", v => { + return parseInt(v) == zoom; + }); +} + +// Bug 934951 - Zoom controls percentage label doesn't update when it's in the toolbar and you navigate. +add_task(async function () { + CustomizableUI.addWidgetToArea("zoom-controls", CustomizableUI.AREA_NAVBAR); + gZoomResetButton = document.getElementById("zoom-reset-button"); + let tab1 = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await BrowserTestUtils.browserLoaded(tab1.linkedBrowser); + let tab2 = BrowserTestUtils.addTab(gBrowser, "about:robots"); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + gBrowser.selectedTab = tab1; + + registerCleanupFunction(() => { + info("Cleaning up."); + CustomizableUI.reset(); + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab1); + }); + + is( + parseInt(gZoomResetButton.label, 10), + 100, + "Default zoom is 100% for about:mozilla" + ); + FullZoom.enlarge(); + await waitForZoom(110); + is( + parseInt(gZoomResetButton.label, 10), + 110, + "Zoom is changed to 110% for about:mozilla" + ); + + let tabSelectPromise = TestUtils.topicObserved( + "browser-fullZoom:location-change" + ); + gBrowser.selectedTab = tab2; + await tabSelectPromise; + await waitForZoom(100); + is( + parseInt(gZoomResetButton.label, 10), + 100, + "Default zoom is 100% for about:robots" + ); + + gBrowser.selectedTab = tab1; + await waitForZoom(110); + FullZoom.reset(); + await waitForZoom(100); + is( + parseInt(gZoomResetButton.label, 10), + 100, + "Default zoom is 100% for about:mozilla" + ); + + // Test zoom label updates while navigating pages in the same tab. + FullZoom.enlarge(); + await waitForZoom(110); + is( + parseInt(gZoomResetButton.label, 10), + 110, + "Zoom is changed to 110% for about:mozilla" + ); + await promiseTabLoadEvent(tab1, "about:home"); + await waitForZoom(100); + is( + parseInt(gZoomResetButton.label, 10), + 100, + "Default zoom is 100% for about:home" + ); + gBrowser.selectedBrowser.goBack(); + await waitForZoom(110); + is( + parseInt(gZoomResetButton.label, 10), + 110, + "Zoom is still 110% for about:mozilla" + ); + FullZoom.reset(); +}); diff --git a/browser/components/customizableui/test/browser_938980_navbar_collapsed.js b/browser/components/customizableui/test/browser_938980_navbar_collapsed.js new file mode 100644 index 0000000000..28b75c0a37 --- /dev/null +++ b/browser/components/customizableui/test/browser_938980_navbar_collapsed.js @@ -0,0 +1,214 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +var bookmarksToolbar = document.getElementById("PersonalToolbar"); +var navbar = document.getElementById("nav-bar"); +var tabsToolbar = document.getElementById("TabsToolbar"); + +// Customization reset should restore visibility to default-visible toolbars. +add_task(async function () { + is(navbar.collapsed, false, "Test should start with navbar visible"); + setToolbarVisibility(navbar, false); + is(navbar.collapsed, true, "navbar should be hidden now"); + + await resetCustomization(); + + is( + navbar.collapsed, + false, + "Customization reset should restore visibility to the navbar" + ); +}); + +// Customization reset should restore collapsed-state to default-collapsed toolbars. +add_task(async function () { + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state" + ); + + is( + bookmarksToolbar.collapsed, + true, + "Test should start with bookmarks toolbar collapsed" + ); + ok(bookmarksToolbar.collapsed, "bookmarksToolbar should be collapsed"); + ok(!tabsToolbar.collapsed, "TabsToolbar should not be collapsed"); + is(navbar.collapsed, false, "The nav-bar should be shown by default"); + + setToolbarVisibility(bookmarksToolbar, true); + setToolbarVisibility(navbar, false); + ok(!bookmarksToolbar.collapsed, "bookmarksToolbar should be visible now"); + ok(navbar.collapsed, "navbar should be collapsed"); + is( + CustomizableUI.inDefaultState, + false, + "Should no longer be in default state" + ); + + await startCustomizing(); + await gCustomizeMode.reset(); + await endCustomizing(); + + is( + bookmarksToolbar.collapsed, + true, + "Customization reset should restore collapsed-state to the bookmarks toolbar" + ); + ok(!tabsToolbar.collapsed, "TabsToolbar should not be collapsed"); + ok( + bookmarksToolbar.collapsed, + "The bookmarksToolbar should be collapsed after reset" + ); + ok( + CustomizableUI.inDefaultState, + "Everything should be back to default state" + ); +}); + +// Check that the menubar will be collapsed by resetting, if the platform supports it. +add_task(async function () { + let menubar = document.getElementById("toolbar-menubar"); + const canMenubarCollapse = CustomizableUI.isToolbarDefaultCollapsed( + menubar.id + ); + if (!canMenubarCollapse) { + return; + } + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state" + ); + + is( + menubar.getBoundingClientRect().height, + 0, + "menubar should be hidden by default" + ); + setToolbarVisibility(menubar, true); + isnot( + menubar.getBoundingClientRect().height, + 0, + "menubar should be visible now" + ); + + await startCustomizing(); + await gCustomizeMode.reset(); + + is( + menubar.getAttribute("autohide"), + "true", + "The menubar should have autohide=true after reset in customization mode" + ); + is( + menubar.getBoundingClientRect().height, + 0, + "The menubar should have height=0 after reset in customization mode" + ); + + await endCustomizing(); + + is( + menubar.getAttribute("autohide"), + "true", + "The menubar should have autohide=true after reset" + ); + is( + menubar.getBoundingClientRect().height, + 0, + "The menubar should have height=0 after reset" + ); +}); + +// Customization reset should restore collapsed-state to default-collapsed toolbars. +add_task(async function () { + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state" + ); + ok(bookmarksToolbar.collapsed, "bookmarksToolbar should be collapsed"); + ok(!tabsToolbar.collapsed, "TabsToolbar should not be collapsed"); + + setToolbarVisibility(bookmarksToolbar, true); + ok(!bookmarksToolbar.collapsed, "bookmarksToolbar should be visible now"); + is( + CustomizableUI.inDefaultState, + false, + "Should no longer be in default state" + ); + + await startCustomizing(); + + ok( + !bookmarksToolbar.collapsed, + "The bookmarksToolbar should be visible before reset" + ); + ok(!navbar.collapsed, "The navbar should be visible before reset"); + ok(!tabsToolbar.collapsed, "TabsToolbar should not be collapsed"); + + await gCustomizeMode.reset(); + + ok( + bookmarksToolbar.collapsed, + "The bookmarksToolbar should be collapsed after reset" + ); + ok(!tabsToolbar.collapsed, "TabsToolbar should not be collapsed"); + ok(!navbar.collapsed, "The navbar should still be visible after reset"); + ok( + CustomizableUI.inDefaultState, + "Everything should be back to default state" + ); + await endCustomizing(); +}); + +// Check that the menubar will be collapsed by resetting, if the platform supports it. +add_task(async function () { + let menubar = document.getElementById("toolbar-menubar"); + const canMenubarCollapse = CustomizableUI.isToolbarDefaultCollapsed( + menubar.id + ); + if (!canMenubarCollapse) { + return; + } + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state" + ); + await startCustomizing(); + let resetButton = document.getElementById("customization-reset-button"); + is( + resetButton.disabled, + true, + "The reset button should be disabled when in default state" + ); + + setToolbarVisibility(menubar, true); + is( + resetButton.disabled, + false, + "The reset button should be enabled when not in default state" + ); + ok( + !CustomizableUI.inDefaultState, + "No longer in default state when the menubar is shown" + ); + + await gCustomizeMode.reset(); + + is( + resetButton.disabled, + true, + "The reset button should be disabled when in default state" + ); + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_938995_indefaultstate_nonremovable.js b/browser/components/customizableui/test/browser_938995_indefaultstate_nonremovable.js new file mode 100644 index 0000000000..2d926e1725 --- /dev/null +++ b/browser/components/customizableui/test/browser_938995_indefaultstate_nonremovable.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kWidgetId = "test-non-removable-widget"; + +// Adding non-removable items to a toolbar or the panel shouldn't change inDefaultState +add_task(async function () { + ok(CustomizableUI.inDefaultState, "Should start in default state"); + + let button = createDummyXULButton( + kWidgetId, + "Test non-removable inDefaultState handling" + ); + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_NAVBAR); + button.setAttribute("removable", "false"); + ok( + CustomizableUI.inDefaultState, + "Should still be in default state after navbar addition" + ); + button.remove(); + + button = createDummyXULButton( + kWidgetId, + "Test non-removable inDefaultState handling" + ); + CustomizableUI.addWidgetToArea( + kWidgetId, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + button.setAttribute("removable", "false"); + ok( + CustomizableUI.inDefaultState, + "Should still be in default state after panel addition" + ); + button.remove(); + ok( + CustomizableUI.inDefaultState, + "Should be in default state after destroying both widgets" + ); + // reset now that button is gone. + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_940013_registerToolbarNode_calls_registerArea.js b/browser/components/customizableui/test/browser_940013_registerToolbarNode_calls_registerArea.js new file mode 100644 index 0000000000..c4fa54f782 --- /dev/null +++ b/browser/components/customizableui/test/browser_940013_registerToolbarNode_calls_registerArea.js @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbarId = "test-registerToolbarNode-toolbar"; +const kButtonId = "test-registerToolbarNode-button"; +registerCleanupFunction(cleanup); + +// Registering a toolbar without a defaultset attribute should +// wait for the registerArea call +add_task(async function () { + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state." + ); + let btn = createDummyXULButton(kButtonId); + let toolbar = document.createXULElement("toolbar"); + toolbar.id = kToolbarId; + toolbar.setAttribute("customizable", true); + gNavToolbox.appendChild(toolbar); + CustomizableUI.registerToolbarNode(toolbar); + ok( + !CustomizableUI.areas.includes(kToolbarId), + "Toolbar should not yet have been registered automatically." + ); + CustomizableUI.registerArea(kToolbarId, { defaultPlacements: [kButtonId] }); + ok( + CustomizableUI.areas.includes(kToolbarId), + "Toolbar should have been registered now." + ); + is( + CustomizableUI.getAreaType(kToolbarId), + CustomizableUI.TYPE_TOOLBAR, + "Area should be registered as toolbar" + ); + assertAreaPlacements(kToolbarId, [kButtonId]); + ok( + !CustomizableUI.inDefaultState, + "No longer in default state after toolbar is registered and visible." + ); + CustomizableUI.unregisterArea(kToolbarId, true); + toolbar.remove(); + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state." + ); + btn.remove(); +}); + +async function cleanup() { + let toolbar = document.getElementById(kToolbarId); + if (toolbar) { + toolbar.remove(); + } + let btn = + document.getElementById(kButtonId) || + gNavToolbox.querySelector("#" + kButtonId); + if (btn) { + btn.remove(); + } + await resetCustomization(); +} diff --git a/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js b/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js new file mode 100644 index 0000000000..0cf9a93341 --- /dev/null +++ b/browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var button, menuButton; +/* Clicking a button should close the panel */ +add_task(async function plain_button() { + button = document.createXULElement("toolbarbutton"); + button.id = "browser_940307_button"; + button.setAttribute("label", "Button"); + gNavToolbox.palette.appendChild(button); + CustomizableUI.addWidgetToArea( + button.id, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + let hiddenAgain = promiseOverflowHidden(window); + EventUtils.synthesizeMouseAtCenter(button, {}); + await hiddenAgain; + CustomizableUI.removeWidgetFromArea(button.id); + button.remove(); +}); + +add_task(async function searchbar_in_panel() { + CustomizableUI.addWidgetToArea( + "search-container", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + + let searchbar = document.getElementById("searchbar"); + await TestUtils.waitForCondition( + () => "value" in searchbar && searchbar.value === "" + ); + + // Focusing a non-empty searchbox will cause us to open the + // autocomplete panel and search for suggestions, which would + // trigger network requests. Temporarily disable suggestions. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", false]], + }); + let dontShowPopup = e => e.preventDefault(); + let searchbarPopup = searchbar.textbox.popup; + searchbarPopup.addEventListener("popupshowing", dontShowPopup); + + searchbar.value = "foo"; + searchbar.focus(); + + // Can't use promisePanelElementShown() here since the search bar + // creates its context menu lazily the first time it is opened. + let contextMenuShown = new Promise(resolve => { + let listener = event => { + if (searchbar._menupopup && event.target == searchbar._menupopup) { + window.removeEventListener("popupshown", listener); + resolve(searchbar._menupopup); + } + }; + window.addEventListener("popupshown", listener); + }); + EventUtils.synthesizeMouseAtCenter(searchbar, { + type: "contextmenu", + button: 2, + }); + let contextmenu = await contextMenuShown; + + ok(isOverflowOpen(), "Panel should still be open"); + + let selectAll = contextmenu.querySelector("[cmd='cmd_selectAll']"); + let contextMenuHidden = promisePanelElementHidden(window, contextmenu); + contextmenu.activateItem(selectAll); + await contextMenuHidden; + + ok(isOverflowOpen(), "Panel should still be open"); + + let hiddenPanelPromise = promiseOverflowHidden(window); + EventUtils.synthesizeKey("KEY_Escape"); + await hiddenPanelPromise; + ok(!isOverflowOpen(), "Panel should no longer be open"); + + // Allow search bar popup to show again. + searchbarPopup.removeEventListener("popupshowing", dontShowPopup); + + // We focused the search bar earlier - ensure we don't keep doing that. + gURLBar.select(); + + CustomizableUI.reset(); +}); + +add_task(async function disabled_button_in_panel() { + button = document.createXULElement("toolbarbutton"); + button.id = "browser_946166_button_disabled"; + button.setAttribute("disabled", "true"); + button.setAttribute("label", "Button"); + gNavToolbox.palette.appendChild(button); + CustomizableUI.addWidgetToArea( + button.id, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + // We intentionally turn off a11y_checks, because the following click + // is targeting a disabled control to confirm the click event won't come through. + // It is not meant to be interactive and is not expected to be accessible: + AccessibilityUtils.setEnv({ + mustBeEnabled: false, + }); + EventUtils.synthesizeMouseAtCenter(button, {}); + AccessibilityUtils.resetEnv(); + is(PanelUI.overflowPanel.state, "open", "Popup stays open"); + button.removeAttribute("disabled"); + let hiddenAgain = promiseOverflowHidden(window); + EventUtils.synthesizeMouseAtCenter(button, {}); + await hiddenAgain; + button.remove(); +}); + +registerCleanupFunction(function () { + if (button && button.parentNode) { + button.remove(); + } + if (menuButton && menuButton.parentNode) { + menuButton.remove(); + } + // Sadly this isn't task.jsm-enabled, so we can't wait for this to happen. But we should + // definitely close it here and hope it won't interfere with other tests. + // Of course, all the tests are meant to do this themselves, but if they fail... + if (isOverflowOpen()) { + PanelUI.overflowPanel.hidePopup(); + } + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_940946_removable_from_navbar_customizemode.js b/browser/components/customizableui/test/browser_940946_removable_from_navbar_customizemode.js new file mode 100644 index 0000000000..2144dd2483 --- /dev/null +++ b/browser/components/customizableui/test/browser_940946_removable_from_navbar_customizemode.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kTestBtnId = "test-removable-navbar-customize-mode"; + +// Items without the removable attribute in the navbar should be considered non-removable +add_task(async function () { + let btn = createDummyXULButton( + kTestBtnId, + "Test removable in navbar in customize mode" + ); + CustomizableUI.getCustomizationTarget( + document.getElementById("nav-bar") + ).appendChild(btn); + await startCustomizing(); + ok( + !CustomizableUI.isWidgetRemovable(kTestBtnId), + "Widget should not be considered removable" + ); + await endCustomizing(); + document.getElementById(kTestBtnId).remove(); +}); + +add_task(async function asyncCleanup() { + await endCustomizing(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_941083_invalidate_wrapper_cache_createWidget.js b/browser/components/customizableui/test/browser_941083_invalidate_wrapper_cache_createWidget.js new file mode 100644 index 0000000000..6df5084849 --- /dev/null +++ b/browser/components/customizableui/test/browser_941083_invalidate_wrapper_cache_createWidget.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// See https://bugzilla.mozilla.org/show_bug.cgi?id=941083 + +const kWidgetId = "test-invalidate-wrapper-cache"; + +// Check createWidget invalidates the widget cache +add_task(function () { + let groupWrapper = CustomizableUI.getWidget(kWidgetId); + ok(groupWrapper, "Should get group wrapper."); + let singleWrapper = groupWrapper.forWindow(window); + ok(singleWrapper, "Should get single wrapper."); + + CustomizableUI.createWidget({ + id: kWidgetId, + label: "Test invalidating widgets caching", + }); + + let newGroupWrapper = CustomizableUI.getWidget(kWidgetId); + ok(newGroupWrapper, "Should get a group wrapper again."); + isnot(newGroupWrapper, groupWrapper, "Wrappers shouldn't be the same."); + isnot( + newGroupWrapper.provider, + groupWrapper.provider, + "Wrapper providers shouldn't be the same." + ); + + let newSingleWrapper = newGroupWrapper.forWindow(window); + isnot( + newSingleWrapper, + singleWrapper, + "Single wrappers shouldn't be the same." + ); + isnot( + newSingleWrapper.provider, + singleWrapper.provider, + "Single wrapper providers shouldn't be the same." + ); + + CustomizableUI.destroyWidget(kWidgetId); + ok( + !CustomizableUI.getWidget(kWidgetId), + "Shouldn't get a wrapper after destroying the widget." + ); +}); diff --git a/browser/components/customizableui/test/browser_942581_unregisterArea_keeps_placements.js b/browser/components/customizableui/test/browser_942581_unregisterArea_keeps_placements.js new file mode 100644 index 0000000000..6f18d590b7 --- /dev/null +++ b/browser/components/customizableui/test/browser_942581_unregisterArea_keeps_placements.js @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbarName = "test-unregisterArea-placements-toolbar"; +const kTestWidgetPfx = "test-widget-for-unregisterArea-placements-"; +const kTestWidgetCount = 3; +registerCleanupFunction(removeCustomToolbars); + +// unregisterArea should keep placements by default and restore them when re-adding the area +add_task(async function () { + let widgetIds = []; + for (let i = 0; i < kTestWidgetCount; i++) { + let id = kTestWidgetPfx + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "unregisterArea test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + } + for (let i = kTestWidgetCount; i < kTestWidgetCount * 2; i++) { + let id = kTestWidgetPfx + i; + widgetIds.push(id); + createDummyXULButton(id, "unregisterArea XUL test " + i); + } + let toolbarNode = createToolbarWithPlacements(kToolbarName, widgetIds); + checkAbstractAndRealPlacements(toolbarNode, widgetIds); + + // Now move one of them: + CustomizableUI.moveWidgetWithinArea(kTestWidgetPfx + kTestWidgetCount, 0); + // Clone the array so we know this is the modified one: + let modifiedWidgetIds = [...widgetIds]; + let movedWidget = modifiedWidgetIds.splice(kTestWidgetCount, 1)[0]; + modifiedWidgetIds.unshift(movedWidget); + + // Check it: + checkAbstractAndRealPlacements(toolbarNode, modifiedWidgetIds); + + // Then unregister + CustomizableUI.unregisterArea(kToolbarName); + + // Check we tell the outside world no dangerous things: + checkWidgetFates(widgetIds); + // Only then remove the real node + toolbarNode.remove(); + + // Now move one of the items to the palette, and another to the navbar: + let lastWidget = modifiedWidgetIds.pop(); + CustomizableUI.removeWidgetFromArea(lastWidget); + lastWidget = modifiedWidgetIds.pop(); + CustomizableUI.addWidgetToArea(lastWidget, CustomizableUI.AREA_NAVBAR); + + // Recreate ourselves with the default placements being the same: + toolbarNode = createToolbarWithPlacements(kToolbarName, widgetIds); + // Then check that after doing this, our actual placements match + // the modified list, not the default one. + checkAbstractAndRealPlacements(toolbarNode, modifiedWidgetIds); + + // Now remove completely: + CustomizableUI.unregisterArea(kToolbarName, true); + checkWidgetFates(modifiedWidgetIds); + toolbarNode.remove(); + + // One more time: + // Recreate ourselves with the default placements being the same: + toolbarNode = createToolbarWithPlacements(kToolbarName, widgetIds); + // Should now be back to default: + checkAbstractAndRealPlacements(toolbarNode, widgetIds); + CustomizableUI.unregisterArea(kToolbarName, true); + checkWidgetFates(widgetIds); + toolbarNode.remove(); + + // XXXgijs: ensure cleanup function doesn't barf: + gAddedToolbars.delete(kToolbarName); + + // Remove all the XUL widgets, destroy the others: + for (let widget of widgetIds) { + let widgetWrapper = CustomizableUI.getWidget(widget); + if (widgetWrapper.provider == CustomizableUI.PROVIDER_XUL) { + gNavToolbox.palette.querySelector("#" + widget).remove(); + } else { + CustomizableUI.destroyWidget(widget); + } + } +}); + +function checkAbstractAndRealPlacements(aNode, aExpectedPlacements) { + assertAreaPlacements(kToolbarName, aExpectedPlacements); + let physicalWidgetIds = Array.from(aNode.children, node => node.id); + placementArraysEqual(aNode.id, physicalWidgetIds, aExpectedPlacements); +} + +function checkWidgetFates(aWidgetIds) { + for (let widget of aWidgetIds) { + ok( + !CustomizableUI.getPlacementOfWidget(widget), + "Widget should be in palette" + ); + ok(!document.getElementById(widget), "Widget should not be in the DOM"); + let widgetInPalette = !!gNavToolbox.palette.querySelector("#" + widget); + let widgetProvider = CustomizableUI.getWidget(widget).provider; + let widgetIsXULWidget = widgetProvider == CustomizableUI.PROVIDER_XUL; + is( + widgetInPalette, + widgetIsXULWidget, + "Just XUL Widgets should be in the palette" + ); + } +} + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_944887_destroyWidget_should_destroy_in_palette.js b/browser/components/customizableui/test/browser_944887_destroyWidget_should_destroy_in_palette.js new file mode 100644 index 0000000000..3da14ab217 --- /dev/null +++ b/browser/components/customizableui/test/browser_944887_destroyWidget_should_destroy_in_palette.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kWidgetId = "test-destroy-in-palette"; + +// Check destroyWidget destroys the node if it's in the palette +add_task(async function () { + CustomizableUI.createWidget({ + id: kWidgetId, + label: "Test destroying widgets in palette.", + }); + await startCustomizing(); + await endCustomizing(); + ok( + gNavToolbox.palette.querySelector("#" + kWidgetId), + "Widget still exists in palette." + ); + CustomizableUI.destroyWidget(kWidgetId); + ok( + !gNavToolbox.palette.querySelector("#" + kWidgetId), + "Widget no longer exists in palette." + ); +}); diff --git a/browser/components/customizableui/test/browser_945739_showInPrivateBrowsing_customize_mode.js b/browser/components/customizableui/test/browser_945739_showInPrivateBrowsing_customize_mode.js new file mode 100644 index 0000000000..c069bd259d --- /dev/null +++ b/browser/components/customizableui/test/browser_945739_showInPrivateBrowsing_customize_mode.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kWidgetId = "test-private-browsing-customize-mode-widget"; + +// Add a widget via the API with showInPrivateBrowsing set to false +// and ensure it does not appear in the list of unused widgets in private +// windows. +add_task(async function testPrivateBrowsingCustomizeModeWidget() { + CustomizableUI.createWidget({ + id: kWidgetId, + showInPrivateBrowsing: false, + }); + + let normalWidgetArray = CustomizableUI.getUnusedWidgets(gNavToolbox.palette); + normalWidgetArray = normalWidgetArray.map(w => w.id); + Assert.greater( + normalWidgetArray.indexOf(kWidgetId), + -1, + "Widget should appear as unused in non-private window" + ); + + let privateWindow = await openAndLoadWindow({ private: true }); + let privateWidgetArray = CustomizableUI.getUnusedWidgets( + privateWindow.gNavToolbox.palette + ); + privateWidgetArray = privateWidgetArray.map(w => w.id); + is( + privateWidgetArray.indexOf(kWidgetId), + -1, + "Widget should not appear as unused in private window" + ); + await promiseWindowClosed(privateWindow); + + CustomizableUI.destroyWidget(kWidgetId); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_copy.js b/browser/components/customizableui/test/browser_947914_button_copy.js new file mode 100644 index 0000000000..e6e0e287c4 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_copy.js @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var initialLocation = gBrowser.currentURI.spec; +var globalClipboard; + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function () { + info("Check copy button existence and functionality"); + CustomizableUI.addWidgetToArea( + "edit-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + let testText = "copy text test"; + + gURLBar.focus(); + info("The URL bar was focused"); + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let copyButton = document.getElementById("copy-button"); + ok(copyButton, "Copy button exists in Panel Menu"); + ok( + copyButton.getAttribute("disabled"), + "Copy button is initially disabled" + ); + + // copy text from URL bar + gURLBar.value = testText; + gURLBar.valueIsTyped = true; + gURLBar.focus(); + gURLBar.select(); + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + ok( + !copyButton.hasAttribute("disabled"), + "Copy button is enabled when selecting" + ); + + await SimpleTest.promiseClipboardChange(testText, () => { + copyButton.click(); + }); + + is( + gURLBar.value, + testText, + "Selected text is unaltered when clicking copy" + ); + } + ); +}); + +registerCleanupFunction(function cleanup() { + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_cut.js b/browser/components/customizableui/test/browser_947914_button_cut.js new file mode 100644 index 0000000000..3ea5622b51 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_cut.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var initialLocation = gBrowser.currentURI.spec; +var globalClipboard; + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function () { + info("Check cut button existence and functionality"); + CustomizableUI.addWidgetToArea( + "edit-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + let testText = "cut text test"; + + gURLBar.focus(); + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let cutButton = document.getElementById("cut-button"); + ok(cutButton, "Cut button exists in Panel Menu"); + ok(cutButton.hasAttribute("disabled"), "Cut button is disabled"); + + // cut text from URL bar + gURLBar.value = testText; + gURLBar.valueIsTyped = true; + gURLBar.focus(); + gURLBar.select(); + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + ok( + !cutButton.hasAttribute("disabled"), + "Cut button is enabled when selecting" + ); + await SimpleTest.promiseClipboardChange(testText, () => { + cutButton.click(); + }); + is( + gURLBar.value, + "", + "Selected text is removed from source when clicking on cut" + ); + } + ); +}); + +registerCleanupFunction(function cleanup() { + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_find.js b/browser/components/customizableui/test/browser_947914_button_find.js new file mode 100644 index 0000000000..c767239d9d --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_find.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + info("Check find button existence and functionality"); + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + CustomizableUI.addWidgetToArea( + "find-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let findButton = document.getElementById("find-button"); + ok(findButton, "Find button exists in Panel Menu"); + + let findBarPromise = gBrowser.isFindBarInitialized() + ? null + : BrowserTestUtils.waitForEvent(gBrowser.selectedTab, "TabFindInitialized"); + + findButton.click(); + await findBarPromise; + ok(!gFindBar.hasAttribute("hidden"), "Findbar opened successfully"); + + // close find bar + gFindBar.close(); + info("Findbar was closed"); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_history.js b/browser/components/customizableui/test/browser_947914_button_history.js new file mode 100644 index 0000000000..d4ad28c04f --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_history.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +add_task(async function () { + info("Check history button existence and functionality"); + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "dummy_history_item.html" + ); + BrowserTestUtils.removeTab(tab); + + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH); // will 404, but we don't care. + + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let historyButton = document.getElementById("history-panelmenu"); + ok(historyButton, "History button appears in Panel Menu"); + + historyButton.click(); + + let historyPanel = document.getElementById("PanelUI-history"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); + await promise; + ok(historyPanel.getAttribute("visible"), "History Panel is in view"); + + let browserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let panelHiddenPromise = promiseOverflowHidden(window); + + let historyItems = document.getElementById("appMenu_historyMenu"); + let historyItemForURL = historyItems.querySelector( + "toolbarbutton.bookmark-item[label='Happy History Hero']" + ); + ok( + historyItemForURL, + "Should have a history item for the history we just made." + ); + EventUtils.synthesizeMouseAtCenter(historyItemForURL, {}); + await browserLoaded; + is( + gBrowser.currentURI.spec, + TEST_PATH + "dummy_history_item.html", + "Should have expected page load" + ); + + await panelHiddenPromise; + BrowserTestUtils.removeTab(tab); + info("Menu panel was closed"); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js b/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js new file mode 100644 index 0000000000..cc8842a3e8 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + info("Check private browsing button existence and functionality"); + CustomizableUI.addWidgetToArea( + "privatebrowsing-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let windowWasHandled = false; + let privateWindow = null; + + let observerWindowOpened = { + observe(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + privateWindow = aSubject; + privateWindow.addEventListener( + "load", + function () { + is( + privateWindow.location.href, + AppConstants.BROWSER_CHROME_URL, + "A new browser window was opened" + ); + ok( + PrivateBrowsingUtils.isWindowPrivate(privateWindow), + "Window is private" + ); + windowWasHandled = true; + }, + { once: true } + ); + } + }, + }; + + Services.ww.registerNotification(observerWindowOpened); + + let privateBrowsingButton = document.getElementById("privatebrowsing-button"); + ok(privateBrowsingButton, "Private browsing button exists in Panel Menu"); + privateBrowsingButton.click(); + + try { + await TestUtils.waitForCondition(() => windowWasHandled); + await promiseWindowClosed(privateWindow); + info("The new private window was closed"); + } catch (e) { + ok(false, "The new private browser window was not properly handled"); + } finally { + Services.ww.unregisterNotification(observerWindowOpened); + } +}); diff --git a/browser/components/customizableui/test/browser_947914_button_newWindow.js b/browser/components/customizableui/test/browser_947914_button_newWindow.js new file mode 100644 index 0000000000..591d13191e --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_newWindow.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + info("Check new window button existence and functionality"); + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let windowWasHandled = false; + let newWindow = null; + + let observerWindowOpened = { + observe(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + newWindow = aSubject; + newWindow.addEventListener( + "load", + function () { + is( + newWindow.location.href, + AppConstants.BROWSER_CHROME_URL, + "A new browser window was opened" + ); + ok( + !PrivateBrowsingUtils.isWindowPrivate(newWindow), + "Window is not private" + ); + windowWasHandled = true; + }, + { once: true } + ); + } + }, + }; + + Services.ww.registerNotification(observerWindowOpened); + + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "New Window button exists in Panel Menu"); + newWindowButton.click(); + + try { + await TestUtils.waitForCondition(() => windowWasHandled); + await promiseWindowClosed(newWindow); + info("The new window was closed"); + } catch (e) { + ok(false, "The new browser window was not properly handled"); + } finally { + Services.ww.unregisterNotification(observerWindowOpened); + } +}); diff --git a/browser/components/customizableui/test/browser_947914_button_paste.js b/browser/components/customizableui/test/browser_947914_button_paste.js new file mode 100644 index 0000000000..a5d107faa6 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_paste.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var initialLocation = gBrowser.currentURI.spec; +var globalClipboard; + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function () { + CustomizableUI.addWidgetToArea( + "edit-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + info("Check paste button existence and functionality"); + + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + globalClipboard = Services.clipboard.kGlobalClipboard; + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let pasteButton = document.getElementById("paste-button"); + ok(pasteButton, "Paste button exists in Panel Menu"); + + // add text to clipboard + let text = "Sample text for testing"; + clipboard.copyString(text); + + // test paste button by pasting text to URL bar + gURLBar.focus(); + await gCUITestUtils.openMainMenu(); + info("Menu panel was opened"); + + ok(!pasteButton.hasAttribute("disabled"), "Paste button is enabled"); + pasteButton.click(); + + is(gURLBar.value, text, "Text pasted successfully"); + + await gCUITestUtils.hideMainMenu(); + } + ); +}); + +registerCleanupFunction(function cleanup() { + CustomizableUI.reset(); + Services.clipboard.emptyClipboard(globalClipboard); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_print.js b/browser/components/customizableui/test/browser_947914_button_print.js new file mode 100644 index 0000000000..29093627f3 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_print.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const isOSX = Services.appinfo.OS === "Darwin"; + +add_task(async function () { + CustomizableUI.addWidgetToArea( + "print-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://example.com/", + }, + async function () { + info("Check print button existence and functionality"); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + await TestUtils.waitForCondition( + () => document.getElementById("print-button") != null + ); + + let printButton = document.getElementById("print-button"); + ok(printButton, "Print button exists in Panel Menu"); + + printButton.click(); + + // Ensure we're showing the preview... + await BrowserTestUtils.waitForCondition(() => { + let preview = document.querySelector(".printPreviewBrowser"); + return preview && BrowserTestUtils.isVisible(preview); + }); + + ok(true, "Entered print preview mode"); + + gBrowser.getTabDialogBox(gBrowser.selectedBrowser).abortAllDialogs(); + // Wait for the preview to go away + await BrowserTestUtils.waitForCondition( + () => !document.querySelector(".printPreviewBrowser") + ); + + info("Exited print preview"); + } + ); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_zoomIn.js b/browser/components/customizableui/test/browser_947914_button_zoomIn.js new file mode 100644 index 0000000000..9982eaa2c4 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_zoomIn.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + info("Check zoom in button existence and functionality"); + + is(ZoomManager.zoom, 1, "Initial zoom factor should be 1"); + + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + registerCleanupFunction(async () => { + CustomizableUI.reset(); + let gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( + Ci.nsIContentPrefService2 + ); + let gLoadContext = Cu.createLoadContext(); + await new Promise(resolve => { + gContentPrefs.removeByName(window.FullZoom.name, gLoadContext, { + handleResult() {}, + handleCompletion() { + resolve(); + }, + }); + }); + }); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let zoomInButton = document.getElementById("zoom-in-button"); + ok(zoomInButton, "Zoom in button exists in Panel Menu"); + + zoomInButton.click(); + let pageZoomLevel = parseInt(ZoomManager.zoom * 100); + info("Page zoom level is: " + pageZoomLevel); + + let zoomResetButton = document.getElementById("zoom-reset-button"); + await TestUtils.waitForCondition(() => { + info( + "Current zoom is " + parseInt(zoomResetButton.getAttribute("label"), 10) + ); + return parseInt(zoomResetButton.getAttribute("label"), 10) == pageZoomLevel; + }); + + Assert.greater(pageZoomLevel, 100, "Page zoomed in correctly"); + + // close the Panel + let panelHiddenPromise = promiseOverflowHidden(window); + document.getElementById("widget-overflow").hidePopup(); + await panelHiddenPromise; + info("Menu panel was closed"); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_zoomOut.js b/browser/components/customizableui/test/browser_947914_button_zoomOut.js new file mode 100644 index 0000000000..d713999a42 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_zoomOut.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + info("Check zoom out button existence and functionality"); + + is(ZoomManager.zoom, 1, "Initial zoom factor should be 1"); + + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + registerCleanupFunction(async () => { + CustomizableUI.reset(); + let gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( + Ci.nsIContentPrefService2 + ); + let gLoadContext = Cu.createLoadContext(); + await new Promise(resolve => { + gContentPrefs.removeByName(window.FullZoom.name, gLoadContext, { + handleResult() {}, + handleCompletion() { + resolve(); + }, + }); + }); + }); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let zoomOutButton = document.getElementById("zoom-out-button"); + ok(zoomOutButton, "Zoom out button exists in Panel Menu"); + + zoomOutButton.click(); + let pageZoomLevel = Math.round(ZoomManager.zoom * 100); + console.log("Page zoom level is: ", pageZoomLevel); + + let zoomResetButton = document.getElementById("zoom-reset-button"); + await TestUtils.waitForCondition(() => { + console.log( + "Current zoom is ", + parseInt(zoomResetButton.getAttribute("label"), 10) + ); + return parseInt(zoomResetButton.getAttribute("label"), 10) == pageZoomLevel; + }); + + Assert.less(pageZoomLevel, 100, "Page zoomed out correctly"); + + // close the panel + let panelHiddenPromise = promiseOverflowHidden(window); + document.getElementById("widget-overflow").hidePopup(); + await panelHiddenPromise; + info("Menu panel was closed"); +}); diff --git a/browser/components/customizableui/test/browser_947914_button_zoomReset.js b/browser/components/customizableui/test/browser_947914_button_zoomReset.js new file mode 100644 index 0000000000..7dc8299b28 --- /dev/null +++ b/browser/components/customizableui/test/browser_947914_button_zoomReset.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var initialPageZoom = ZoomManager.zoom; + +add_task(async function () { + info("Check zoom reset button existence and functionality"); + is(initialPageZoom, 1, "Page zoom reset correctly"); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "http://example.com", waitForLoad: true }, + async function (browser) { + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + registerCleanupFunction(() => CustomizableUI.reset()); + + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + { + let zoomChange = BrowserTestUtils.waitForEvent( + gBrowser, + "FullZoomChange" + ); + ZoomManager.zoom = 0.5; + await zoomChange; + } + + await document.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let zoomResetButton = document.getElementById("zoom-reset-button"); + ok(zoomResetButton, "Zoom reset button exists in Panel Menu"); + + let zoomChange = BrowserTestUtils.waitForEvent( + gBrowser, + "FullZoomChange" + ); + zoomResetButton.click(); + await zoomChange; + + let pageZoomLevel = Math.floor(ZoomManager.zoom * 100); + let expectedZoomLevel = 100; + let buttonZoomLevel = parseInt(zoomResetButton.getAttribute("label"), 10); + is(pageZoomLevel, expectedZoomLevel, "Page zoom reset correctly"); + is( + pageZoomLevel, + buttonZoomLevel, + "Button displays the correct zoom level" + ); + + // close the panel + let panelHiddenPromise = promiseOverflowHidden(window); + document.getElementById("widget-overflow").hidePopup(); + await panelHiddenPromise; + info("Menu panel was closed"); + } + ); +}); + +add_task(async function asyncCleanup() { + // reset zoom level + ZoomManager.zoom = initialPageZoom; + info("Zoom level was restored"); +}); diff --git a/browser/components/customizableui/test/browser_947987_removable_default.js b/browser/components/customizableui/test/browser_947987_removable_default.js new file mode 100644 index 0000000000..84bbd3ed59 --- /dev/null +++ b/browser/components/customizableui/test/browser_947987_removable_default.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var kWidgetId = "test-removable-widget-default"; +const kNavBar = CustomizableUI.AREA_NAVBAR; +var widgetCounter = 0; + +registerCleanupFunction(removeCustomToolbars); + +// Sanity checks +add_task(function () { + let brokenSpec = { id: kWidgetId + widgetCounter++, removable: false }; + SimpleTest.doesThrow( + () => CustomizableUI.createWidget(brokenSpec), + "Creating non-removable widget without defaultArea should throw." + ); + + // Widget without removable set should be removable: + let wrapper = CustomizableUI.createWidget({ + id: kWidgetId + widgetCounter++, + }); + ok( + CustomizableUI.isWidgetRemovable(wrapper.id), + "Should be removable by default." + ); + CustomizableUI.destroyWidget(wrapper.id); +}); + +// Test non-removable widget with defaultArea +add_task(async function () { + // Non-removable widget with defaultArea should work: + let spec = { + id: kWidgetId + widgetCounter++, + removable: false, + defaultArea: kNavBar, + }; + let widgetWrapper; + try { + widgetWrapper = CustomizableUI.createWidget(spec); + } catch (ex) { + ok( + false, + "Creating a non-removable widget with a default area should not throw." + ); + return; + } + + let placement = CustomizableUI.getPlacementOfWidget(spec.id); + ok(placement, "Widget should be placed."); + is(placement.area, kNavBar, "Widget should be in navbar"); + let singleWrapper = widgetWrapper.forWindow(window); + ok(singleWrapper, "Widget should exist in window."); + ok(singleWrapper.node, "Widget node should exist in window."); + let expectedParent = CustomizableUI.getCustomizeTargetForArea( + kNavBar, + window + ); + is( + singleWrapper.node.parentNode, + expectedParent, + "Widget should be in navbar." + ); + + let otherWin = await openAndLoadWindow(true); + placement = CustomizableUI.getPlacementOfWidget(spec.id); + ok(placement, "Widget should be placed."); + is(placement && placement.area, kNavBar, "Widget should be in navbar"); + + singleWrapper = widgetWrapper.forWindow(otherWin); + ok(singleWrapper, "Widget should exist in other window."); + if (singleWrapper) { + ok(singleWrapper.node, "Widget node should exist in other window."); + if (singleWrapper.node) { + let expectedParentInOtherWin = CustomizableUI.getCustomizeTargetForArea( + kNavBar, + otherWin + ); + is( + singleWrapper.node.parentNode, + expectedParentInOtherWin, + "Widget should be in navbar in other window." + ); + } + } + CustomizableUI.destroyWidget(spec.id); + await promiseWindowClosed(otherWin); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_948985_non_removable_defaultArea.js b/browser/components/customizableui/test/browser_948985_non_removable_defaultArea.js new file mode 100644 index 0000000000..ce8d3c2d3a --- /dev/null +++ b/browser/components/customizableui/test/browser_948985_non_removable_defaultArea.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const kWidgetId = "test-destroy-non-removable-defaultArea"; + +add_task(function () { + let spec = { + id: kWidgetId, + label: "Test non-removable defaultArea re-adding.", + removable: false, + defaultArea: CustomizableUI.AREA_NAVBAR, + }; + CustomizableUI.createWidget(spec); + let placement = CustomizableUI.getPlacementOfWidget(kWidgetId); + ok(placement, "Should have placed the widget."); + is( + placement && placement.area, + CustomizableUI.AREA_NAVBAR, + "Widget should be in navbar" + ); + CustomizableUI.destroyWidget(kWidgetId); + CustomizableUI.removeWidgetFromArea(kWidgetId); + + CustomizableUI.createWidget(spec); + ok(placement, "Should have placed the widget."); + is( + placement && placement.area, + CustomizableUI.AREA_NAVBAR, + "Widget should be in navbar" + ); + CustomizableUI.destroyWidget(kWidgetId); + CustomizableUI.removeWidgetFromArea(kWidgetId); + + const kPrefCustomizationAutoAdd = "browser.uiCustomization.autoAdd"; + Services.prefs.setBoolPref(kPrefCustomizationAutoAdd, false); + CustomizableUI.createWidget(spec); + ok(placement, "Should have placed the widget."); + is( + placement && placement.area, + CustomizableUI.AREA_NAVBAR, + "Widget should be in navbar" + ); + CustomizableUI.destroyWidget(kWidgetId); + CustomizableUI.removeWidgetFromArea(kWidgetId); + Services.prefs.clearUserPref(kPrefCustomizationAutoAdd); +}); diff --git a/browser/components/customizableui/test/browser_952963_areaType_getter_no_area.js b/browser/components/customizableui/test/browser_952963_areaType_getter_no_area.js new file mode 100644 index 0000000000..1dab702fc8 --- /dev/null +++ b/browser/components/customizableui/test/browser_952963_areaType_getter_no_area.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbarName = "test-unregisterArea-areaType"; +const kUnregisterAreaTestWidget = "test-widget-for-unregisterArea-areaType"; +const kTestWidget = "test-widget-no-area-areaType"; +registerCleanupFunction(removeCustomToolbars); + +registerCleanupFunction(() => { + try { + CustomizableUI.destroyWidget(kTestWidget); + CustomizableUI.destroyWidget(kUnregisterAreaTestWidget); + } catch (ex) { + console.error(ex); + } +}); + +function checkAreaType(widget) { + try { + // widget.areaType returns either null or undefined + ok(!widget.areaType, "areaType should be null"); + } catch (ex) { + info("Fetching areaType threw: " + ex); + ok(false, "areaType getter shouldn't throw."); + } +} + +// widget wrappers in unregisterArea'd areas and nowhere shouldn't throw when checking areaTypes. +add_task(async function () { + // Using the ID before it's been created will imply a XUL wrapper; we'll test + // an API-based wrapper below + let toolbarNode = createToolbarWithPlacements(kToolbarName, [ + kUnregisterAreaTestWidget, + ]); + CustomizableUI.unregisterArea(kToolbarName); + toolbarNode.remove(); + + let w = CustomizableUI.getWidget(kUnregisterAreaTestWidget); + checkAreaType(w); + + w = CustomizableUI.getWidget(kTestWidget); + checkAreaType(w); + + let spec = { + id: kUnregisterAreaTestWidget, + type: "button", + removable: true, + label: "areaType test", + tooltiptext: "areaType test", + }; + CustomizableUI.createWidget(spec); + toolbarNode = createToolbarWithPlacements(kToolbarName, [ + kUnregisterAreaTestWidget, + ]); + CustomizableUI.unregisterArea(kToolbarName); + toolbarNode.remove(); + w = CustomizableUI.getWidget(spec.id); + checkAreaType(w); + CustomizableUI.removeWidgetFromArea(kUnregisterAreaTestWidget); + checkAreaType(w); + // XXXgijs: ensure cleanup function doesn't barf: + gAddedToolbars.delete(kToolbarName); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_956602_remove_special_widget.js b/browser/components/customizableui/test/browser_956602_remove_special_widget.js new file mode 100644 index 0000000000..237103b79e --- /dev/null +++ b/browser/components/customizableui/test/browser_956602_remove_special_widget.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Adding a separator and then dragging it out of the navbar shouldn't throw +add_task(async function () { + try { + let navbar = document.getElementById("nav-bar"); + let separatorSelector = + "toolbarseparator[id^=customizableui-special-separator]"; + ok( + !navbar.querySelector(separatorSelector), + "Shouldn't be a separator in the navbar" + ); + CustomizableUI.addWidgetToArea("separator", "nav-bar"); + await startCustomizing(); + let separator = navbar.querySelector(separatorSelector); + ok(separator, "There should be a separator in the navbar now."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(separator, palette); + ok( + !palette.querySelector(separatorSelector), + "No separator in the palette." + ); + } catch (ex) { + console.error(ex); + ok(false, "Shouldn't throw an exception moving an item to the navbar."); + } finally { + await endCustomizing(); + } +}); + +add_task(async function asyncCleanup() { + resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_962069_drag_to_overflow_chevron.js b/browser/components/customizableui/test/browser_962069_drag_to_overflow_chevron.js new file mode 100644 index 0000000000..5337110482 --- /dev/null +++ b/browser/components/customizableui/test/browser_962069_drag_to_overflow_chevron.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var originalWindowWidth; + +// Drag to overflow chevron should open the overflow panel. +add_task(async function () { + // Load a page so the identity box can be dragged. + BrowserTestUtils.startLoadingURIString(gBrowser, "http://mochi.test:8888/"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + originalWindowWidth = window.outerWidth; + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + ok(CustomizableUI.inDefaultState, "Should start in default state."); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + + let widgetOverflowPanel = document.getElementById("widget-overflow"); + let panelShownPromise = promisePanelElementShown(window, widgetOverflowPanel); + let identityBox = document.getElementById("identity-icon-box"); + let overflowChevron = document.getElementById("nav-bar-overflow-button"); + + // Listen for hiding immediately so we don't miss the event because of the + // async-ness of the 'shown' yield... + let panelHiddenPromise = promisePanelElementHidden( + window, + widgetOverflowPanel + ); + + var ds = Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); + + ds.startDragSessionForTests( + Ci.nsIDragService.DRAGDROP_ACTION_MOVE | + Ci.nsIDragService.DRAGDROP_ACTION_COPY | + Ci.nsIDragService.DRAGDROP_ACTION_LINK + ); + try { + var [result, dataTransfer] = EventUtils.synthesizeDragOver( + identityBox, + overflowChevron + ); + + // Wait for showing panel before ending drag session. + await panelShownPromise; + + EventUtils.synthesizeDropAfterDragOver( + result, + dataTransfer, + overflowChevron + ); + } finally { + ds.endDragSession(true); + } + + info("Overflow panel is shown."); + + widgetOverflowPanel.hidePopup(); + await panelHiddenPromise; +}); + +add_task(async function () { + window.resizeTo(originalWindowWidth, window.outerHeight); + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should not have an overflowing toolbar." + ); +}); diff --git a/browser/components/customizableui/test/browser_963639_customizing_attribute_non_customizable_toolbar.js b/browser/components/customizableui/test/browser_963639_customizing_attribute_non_customizable_toolbar.js new file mode 100644 index 0000000000..db829ab411 --- /dev/null +++ b/browser/components/customizableui/test/browser_963639_customizing_attribute_non_customizable_toolbar.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbar = "test-toolbar-963639-non-customizable-customizing-attribute"; + +add_task(async function () { + info( + "Test for Bug 963639 - CustomizeMode _onToolbarVisibilityChange sets @customizing on non-customizable toolbars" + ); + + let toolbar = document.createXULElement("toolbar"); + toolbar.id = kToolbar; + gNavToolbox.appendChild(toolbar); + + let testToolbar = document.getElementById(kToolbar); + ok(testToolbar, "Toolbar was created."); + is( + gNavToolbox.getElementsByAttribute("id", kToolbar).length, + 1, + "Toolbar was added to the navigator toolbox" + ); + + toolbar.setAttribute( + "toolbarname", + "NonCustomizableToolbarCustomizingAttribute" + ); + toolbar.setAttribute("collapsed", "true"); + + await startCustomizing(); + window.setToolbarVisibility(toolbar, "true"); + isnot( + toolbar.getAttribute("customizing"), + "true", + "Toolbar doesn't have the customizing attribute" + ); + + await endCustomizing(); + gNavToolbox.removeChild(toolbar); + + is( + gNavToolbox.getElementsByAttribute("id", kToolbar).length, + 0, + "Toolbar was removed from the navigator toolbox" + ); +}); diff --git a/browser/components/customizableui/test/browser_968565_insert_before_hidden_items.js b/browser/components/customizableui/test/browser_968565_insert_before_hidden_items.js new file mode 100644 index 0000000000..dbc45880d2 --- /dev/null +++ b/browser/components/customizableui/test/browser_968565_insert_before_hidden_items.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kHidden1Id = "test-hidden-button-1"; +const kHidden2Id = "test-hidden-button-2"; + +var navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + +// When we drag an item onto a customizable area, and not over a specific target, we +// should assume that we're appending them to the area. If doing so, we should scan +// backwards over any hidden items and insert the item before those hidden items. +add_task(async function () { + ok(CustomizableUI.inDefaultState, "Should be in the default state"); + + // Iterate backwards over the items in the nav-bar until we find the first + // one that is not hidden. + let placements = CustomizableUI.getWidgetsInArea(CustomizableUI.AREA_NAVBAR); + let lastVisible = null; + for (let widgetGroup of placements.reverse()) { + let widget = widgetGroup.forWindow(window); + if (widget && widget.node && !widget.node.hidden) { + lastVisible = widget.node; + break; + } + } + + if (!lastVisible) { + ok(false, "Apparently, there are no visible items in the nav-bar."); + } + + info("The last visible item in the nav-bar has ID: " + lastVisible.id); + + let hidden1 = createDummyXULButton(kHidden1Id, "You can't see me"); + let hidden2 = createDummyXULButton(kHidden2Id, "You can't see me either."); + hidden1.hidden = hidden2.hidden = true; + + // Make sure we have some hidden items at the end of the nav-bar. + CustomizableUI.addWidgetToArea(kHidden1Id, "nav-bar"); + CustomizableUI.addWidgetToArea(kHidden2Id, "nav-bar"); + + // Drag an item and drop it onto the nav-bar customization target, but + // not over a particular item. + await startCustomizing(); + let homeButton = document.getElementById("home-button"); + let navbarTarget = CustomizableUI.getCustomizationTarget(navbar); + simulateItemDrag(homeButton, navbarTarget, "end"); + + await endCustomizing(); + + is( + homeButton.previousElementSibling.id, + lastVisible.id, + "The downloads button should be placed after the last visible item." + ); + + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_969427_recreate_destroyed_widget_after_reset.js b/browser/components/customizableui/test/browser_969427_recreate_destroyed_widget_after_reset.js new file mode 100644 index 0000000000..8207cd5737 --- /dev/null +++ b/browser/components/customizableui/test/browser_969427_recreate_destroyed_widget_after_reset.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +function getPlacementArea(id) { + let placement = CustomizableUI.getPlacementOfWidget(id); + return placement && placement.area; +} + +// Check that a destroyed widget recreated after a reset call goes to +// the navigation bar. +add_task(function () { + const kWidgetId = "test-recreate-after-reset"; + let spec = { + id: kWidgetId, + label: "Test re-create after reset.", + removable: true, + defaultArea: CustomizableUI.AREA_NAVBAR, + }; + + CustomizableUI.createWidget(spec); + is( + getPlacementArea(kWidgetId), + CustomizableUI.AREA_NAVBAR, + "widget is in the navigation bar" + ); + + CustomizableUI.destroyWidget(kWidgetId); + isnot( + getPlacementArea(kWidgetId), + CustomizableUI.AREA_NAVBAR, + "widget removed from the navigation bar" + ); + + CustomizableUI.reset(); + + CustomizableUI.createWidget(spec); + is( + getPlacementArea(kWidgetId), + CustomizableUI.AREA_NAVBAR, + "widget recreated and added back to the nav bar" + ); + + CustomizableUI.destroyWidget(kWidgetId); +}); diff --git a/browser/components/customizableui/test/browser_969661_character_encoding_navbar_disabled.js b/browser/components/customizableui/test/browser_969661_character_encoding_navbar_disabled.js new file mode 100644 index 0000000000..71e83274c2 --- /dev/null +++ b/browser/components/customizableui/test/browser_969661_character_encoding_navbar_disabled.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Adding the character encoding menu to the panel, exiting customize mode, +// and moving it to the nav-bar should have it disabled if the page in the +// content area isn't eligible to have its encoding overridden. +add_task(async function () { + await startCustomizing(); + CustomizableUI.addWidgetToArea( + "characterencoding-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await endCustomizing(); + await document.getElementById("nav-bar").overflowable.show(); + let panelHiddenPromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHiddenPromise; + CustomizableUI.addWidgetToArea("characterencoding-button", "nav-bar"); + let button = document.getElementById("characterencoding-button"); + ok(button.hasAttribute("disabled"), "Button should be disabled"); +}); + +add_task(function asyncCleanup() { + resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_970511_undo_restore_default.js b/browser/components/customizableui/test/browser_970511_undo_restore_default.js new file mode 100644 index 0000000000..5477b41b80 --- /dev/null +++ b/browser/components/customizableui/test/browser_970511_undo_restore_default.js @@ -0,0 +1,274 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +// Restoring default should reset density and show an "undo" option which undoes +// the restoring operation. +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.compactmode.show", true]], + }); + let stopReloadButtonId = "stop-reload-button"; + CustomizableUI.removeWidgetFromArea(stopReloadButtonId); + await startCustomizing(); + ok(!CustomizableUI.inDefaultState, "Not in default state to begin with"); + is( + CustomizableUI.getPlacementOfWidget(stopReloadButtonId), + null, + "Stop/reload button is in palette" + ); + let undoResetButton = document.getElementById( + "customization-undo-reset-button" + ); + is(undoResetButton.hidden, true, "The undo button is hidden before reset"); + + let densityButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(densityButton, {}); + info("Clicked on density button"); + await popupShownPromise; + + let compactModeItem = document.getElementById( + "customization-uidensity-menuitem-compact" + ); + let win = document.getElementById("main-window"); + let densityChangedPromise = new Promise(resolve => { + let observer = new MutationObserver(() => { + if (win.getAttribute("uidensity") == "compact") { + resolve(); + observer.disconnect(); + } + }); + observer.observe(win, { + attributes: true, + attributeFilter: ["uidensity"], + }); + }); + + compactModeItem.doCommand(); + info("Clicked on compact density"); + await densityChangedPromise; + + await gCustomizeMode.reset(); + + ok(CustomizableUI.inDefaultState, "In default state after reset"); + is(undoResetButton.hidden, false, "The undo button is visible after reset"); + is( + win.hasAttribute("uidensity"), + false, + "The window has been restored to normal density." + ); + + await gCustomizeMode.undoReset(); + + is( + win.getAttribute("uidensity"), + "compact", + "Density has been reset to compact." + ); + ok(!CustomizableUI.inDefaultState, "Not in default state after undo-reset"); + is( + undoResetButton.hidden, + true, + "The undo button is hidden after clicking on the undo button" + ); + is( + CustomizableUI.getPlacementOfWidget(stopReloadButtonId), + null, + "Stop/reload button is in palette" + ); + + await gCustomizeMode.reset(); + await SpecialPowers.popPrefEnv(); +}); + +// Performing an action after a reset will hide the undo button. +add_task(async function action_after_reset_hides_undo() { + let stopReloadButtonId = "stop-reload-button"; + CustomizableUI.removeWidgetFromArea(stopReloadButtonId); + ok(!CustomizableUI.inDefaultState, "Not in default state to begin with"); + is( + CustomizableUI.getPlacementOfWidget(stopReloadButtonId), + null, + "Stop/reload button is in palette" + ); + let undoResetButton = document.getElementById( + "customization-undo-reset-button" + ); + is(undoResetButton.hidden, true, "The undo button is hidden before reset"); + + await gCustomizeMode.reset(); + + ok(CustomizableUI.inDefaultState, "In default state after reset"); + is(undoResetButton.hidden, false, "The undo button is visible after reset"); + + CustomizableUI.addWidgetToArea( + stopReloadButtonId, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + is( + undoResetButton.hidden, + true, + "The undo button is hidden after another change" + ); +}); + +// "Restore defaults", exiting customize, and re-entering shouldn't show the Undo button +add_task(async function () { + let undoResetButton = document.getElementById( + "customization-undo-reset-button" + ); + is(undoResetButton.hidden, true, "The undo button is hidden before a reset"); + ok( + !CustomizableUI.inDefaultState, + "The browser should not be in default state" + ); + await gCustomizeMode.reset(); + + is(undoResetButton.hidden, false, "The undo button is visible after a reset"); + await endCustomizing(); + await startCustomizing(); + is( + undoResetButton.hidden, + true, + "The undo reset button should be hidden after entering customization mode" + ); +}); + +// Bug 971626 - Restore Defaults should collapse the Title Bar +add_task(async function () { + { + const supported = TabsInTitlebar.systemSupported; + is(typeof supported, "boolean"); + info("TabsInTitlebar support: " + supported); + if (!supported) { + return; + } + } + + const kDefaultValue = Services.appinfo.drawInTitlebar; + let restoreDefaultsButton = document.getElementById( + "customization-reset-button" + ); + let titlebarCheckbox = document.getElementById( + "customization-titlebar-visibility-checkbox" + ); + let undoResetButton = document.getElementById( + "customization-undo-reset-button" + ); + ok( + CustomizableUI.inDefaultState, + "Should be in default state at start of test" + ); + ok( + restoreDefaultsButton.disabled, + "Restore defaults button should be disabled when in default state" + ); + is( + titlebarCheckbox.hasAttribute("checked"), + !kDefaultValue, + "Title bar checkbox should reflect pref value" + ); + is( + undoResetButton.hidden, + true, + "Undo reset button should be hidden at start of test" + ); + + let prefName = "browser.tabs.inTitlebar"; + Services.prefs.setIntPref(prefName, !kDefaultValue); + ok( + !restoreDefaultsButton.disabled, + "Restore defaults button should be enabled when pref changed" + ); + is( + Services.appinfo.drawInTitlebar, + !kDefaultValue, + "Title bar checkbox should reflect changed pref value" + ); + is( + titlebarCheckbox.hasAttribute("checked"), + kDefaultValue, + "Title bar checkbox should reflect changed pref value" + ); + ok( + !CustomizableUI.inDefaultState, + "With titlebar flipped, no longer default" + ); + is( + undoResetButton.hidden, + true, + "Undo reset button should be hidden after pref change" + ); + + await gCustomizeMode.reset(); + ok( + restoreDefaultsButton.disabled, + "Restore defaults button should be disabled after reset" + ); + is( + titlebarCheckbox.hasAttribute("checked"), + !kDefaultValue, + "Title bar checkbox should reflect default value after reset" + ); + is( + Services.prefs.getIntPref(prefName), + 2, + "Reset should reset drawInTitlebar" + ); + is( + Services.appinfo.drawInTitlebar, + kDefaultValue, + "Default state should be restored" + ); + ok(CustomizableUI.inDefaultState, "In default state after titlebar reset"); + is( + undoResetButton.hidden, + false, + "Undo reset button should be visible after reset" + ); + ok( + !undoResetButton.disabled, + "Undo reset button should be enabled after reset" + ); + + await gCustomizeMode.undoReset(); + ok( + !restoreDefaultsButton.disabled, + "Restore defaults button should be enabled after undo-reset" + ); + is( + titlebarCheckbox.hasAttribute("checked"), + kDefaultValue, + "Title bar checkbox should reflect undo-reset value" + ); + ok(!CustomizableUI.inDefaultState, "No longer in default state after undo"); + is( + Services.prefs.getIntPref(prefName), + kDefaultValue ? 0 : 1, + "Undo-reset goes back to previous pref value" + ); + is( + undoResetButton.hidden, + true, + "Undo reset button should be hidden after undo-reset clicked" + ); + + Services.prefs.clearUserPref(prefName); + ok(CustomizableUI.inDefaultState, "In default state after pref cleared"); + is( + undoResetButton.hidden, + true, + "Undo reset button should be hidden at end of test" + ); +}); + +add_task(async function asyncCleanup() { + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_972267_customizationchange_events.js b/browser/components/customizableui/test/browser_972267_customizationchange_events.js new file mode 100644 index 0000000000..7d27b94136 --- /dev/null +++ b/browser/components/customizableui/test/browser_972267_customizationchange_events.js @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Create a new window, then move the stop/reload button to the menu and check both windows have +// customizationchange events fire on the toolbox: +add_task(async function () { + let newWindow = await openAndLoadWindow(); + let otherToolbox = newWindow.gNavToolbox; + + let handlerCalledCount = 0; + let handler = ev => { + handlerCalledCount++; + }; + + let stopReloadButton = document.getElementById("stop-reload-button"); + + gNavToolbox.addEventListener("customizationchange", handler); + otherToolbox.addEventListener("customizationchange", handler); + + await gCustomizeMode.addToPanel(stopReloadButton); + + is(handlerCalledCount, 2, "Should be called for both windows."); + + handlerCalledCount = 0; + gCustomizeMode.addToToolbar(stopReloadButton); + is(handlerCalledCount, 2, "Should be called for both windows."); + + gNavToolbox.removeEventListener("customizationchange", handler); + otherToolbox.removeEventListener("customizationchange", handler); + + await promiseWindowClosed(newWindow); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_976792_insertNodeInWindow.js b/browser/components/customizableui/test/browser_976792_insertNodeInWindow.js new file mode 100644 index 0000000000..6a8cb26958 --- /dev/null +++ b/browser/components/customizableui/test/browser_976792_insertNodeInWindow.js @@ -0,0 +1,597 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbarName = "test-insertNodeInWindow-placements-toolbar"; +const kTestWidgetPrefix = "test-widget-for-insertNodeInWindow-placements-"; + +/* +Tries to replicate the situation of having a placement list like this: + +exists-1,trying-to-insert-this,doesn't-exist,exists-2 +*/ +add_task(async function () { + let testWidgetExists = [true, false, false, true]; + let widgetIds = []; + for (let i = 0; i < testWidgetExists.length; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + if (testWidgetExists[i]) { + let spec = { + id, + type: "button", + removable: true, + label: "test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + } + } + + let toolbarNode = createToolbarWithPlacements(kToolbarName, widgetIds); + assertAreaPlacements(kToolbarName, widgetIds); + + let btnId = kTestWidgetPrefix + 1; + let btn = createDummyXULButton(btnId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(btnId, window); + + is( + btn.parentNode.id, + kToolbarName, + "New XUL widget should be placed inside new toolbar" + ); + + is( + btn.previousElementSibling.id, + toolbarNode.firstElementChild.id, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + btn.remove(); + removeCustomToolbars(); + await resetCustomization(); +}); + +/* +Tests nodes get placed inside the toolbar's overflow as expected. Replicates a +situation similar to: + +exists-1,exists-2,overflow-1,trying-to-insert-this,overflow-2 +*/ +add_task(async function () { + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + + let widgetIds = []; + for (let i = 0; i < 5; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "insertNodeInWindow test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + CustomizableUI.addWidgetToArea(id, "nav-bar"); + } + + for (let id of widgetIds) { + document.getElementById(id).style.minWidth = "200px"; + } + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + navbar.hasAttribute("overflowing") && + !navbar.querySelector("#" + widgetIds[0]) + ); + + let testWidgetId = kTestWidgetPrefix + 3; + + CustomizableUI.destroyWidget(testWidgetId); + + let btn = createDummyXULButton(testWidgetId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(testWidgetId, window); + + ok( + navbar.overflowable.isInOverflowList(btn), + "New XUL widget should be placed inside overflow of toolbar" + ); + is( + btn.previousElementSibling.id, + kTestWidgetPrefix + 2, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + is( + btn.nextElementSibling.id, + kTestWidgetPrefix + 4, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + CustomizableUI.removeWidgetFromArea(btn.id, kToolbarName); + btn.remove(); + await resetCustomization(); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +/* +Tests nodes get placed inside the toolbar's overflow as expected. Replicates a +placements situation similar to: + +exists-1,exists-2,overflow-1,doesn't-exist,trying-to-insert-this,overflow-2 +*/ +add_task(async function () { + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + + let widgetIds = []; + for (let i = 0; i < 5; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "insertNodeInWindow test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + CustomizableUI.addWidgetToArea(id, "nav-bar"); + } + + for (let id of widgetIds) { + document.getElementById(id).style.minWidth = "200px"; + } + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + navbar.hasAttribute("overflowing") && + !navbar.querySelector("#" + widgetIds[0]) + ); + + let testWidgetId = kTestWidgetPrefix + 3; + + CustomizableUI.destroyWidget(kTestWidgetPrefix + 2); + CustomizableUI.destroyWidget(testWidgetId); + + let btn = createDummyXULButton(testWidgetId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(testWidgetId, window); + + ok( + navbar.overflowable.isInOverflowList(btn), + "New XUL widget should be placed inside overflow of toolbar" + ); + is( + btn.previousElementSibling.id, + kTestWidgetPrefix + 1, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + is( + btn.nextElementSibling.id, + kTestWidgetPrefix + 4, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + CustomizableUI.removeWidgetFromArea(btn.id, kToolbarName); + btn.remove(); + await resetCustomization(); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +/* +Tests nodes get placed inside the toolbar's overflow as expected. Replicates a +placements situation similar to: + +exists-1,exists-2,overflow-1,doesn't-exist,trying-to-insert-this,doesn't-exist +*/ +add_task(async function () { + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + + let widgetIds = []; + for (let i = 0; i < 5; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "insertNodeInWindow test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + CustomizableUI.addWidgetToArea(id, "nav-bar"); + } + + for (let id of widgetIds) { + document.getElementById(id).style.minWidth = "200px"; + } + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + navbar.hasAttribute("overflowing") && + !navbar.querySelector("#" + widgetIds[0]) + ); + + let testWidgetId = kTestWidgetPrefix + 3; + + CustomizableUI.destroyWidget(kTestWidgetPrefix + 2); + CustomizableUI.destroyWidget(testWidgetId); + CustomizableUI.destroyWidget(kTestWidgetPrefix + 4); + + let btn = createDummyXULButton(testWidgetId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(testWidgetId, window); + + ok( + navbar.overflowable.isInOverflowList(btn), + "New XUL widget should be placed inside overflow of toolbar" + ); + is( + btn.previousElementSibling.id, + kTestWidgetPrefix + 1, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + is( + btn.nextElementSibling, + null, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + CustomizableUI.removeWidgetFromArea(btn.id, kToolbarName); + btn.remove(); + await resetCustomization(); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +/* +Tests nodes get placed inside the toolbar's overflow as expected. Replicates a +placements situation similar to: + +exists-1,exists-2,overflow-1,can't-overflow,trying-to-insert-this,overflow-2 +*/ +add_task(async function () { + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + + let widgetIds = []; + for (let i = 5; i >= 0; i--) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "insertNodeInWindow test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + CustomizableUI.addWidgetToArea(id, "nav-bar", 0); + } + + for (let i = 10; i < 15; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "insertNodeInWindow test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + CustomizableUI.addWidgetToArea(id, "nav-bar"); + } + + for (let id of widgetIds) { + document.getElementById(id).style.minWidth = "200px"; + } + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + // Wait for all the widgets to overflow. We can't just wait for the + // `overflowing` attribute because we leave time for layout flushes + // inbetween, so it's possible for the timeout to run before the + // navbar has "settled" + await TestUtils.waitForCondition(() => { + return ( + navbar.hasAttribute("overflowing") && + CustomizableUI.getCustomizationTarget( + navbar + ).lastElementChild.getAttribute("overflows") == "false" + ); + }); + + // Find last widget that doesn't allow overflowing + let nonOverflowing = + CustomizableUI.getCustomizationTarget(navbar).lastElementChild; + is( + nonOverflowing.getAttribute("overflows"), + "false", + "Last child is expected to not allow overflowing" + ); + isnot( + nonOverflowing.getAttribute("skipintoolbarset"), + "true", + "Last child is expected to not be skipintoolbarset" + ); + + let testWidgetId = kTestWidgetPrefix + 10; + CustomizableUI.destroyWidget(testWidgetId); + + let btn = createDummyXULButton(testWidgetId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(testWidgetId, window); + + ok( + navbar.overflowable.isInOverflowList(btn), + "New XUL widget should be placed inside overflow of toolbar" + ); + is( + btn.nextElementSibling.id, + kTestWidgetPrefix + 11, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + CustomizableUI.removeWidgetFromArea(btn.id, kToolbarName); + btn.remove(); + await resetCustomization(); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +/* +Tests nodes get placed inside the toolbar's overflow as expected. Replicates a +placements situation similar to: + +exists-1,exists-2,overflow-1,trying-to-insert-this,can't-overflow,overflow-2 +*/ +add_task(async function () { + let widgetIds = []; + let missingId = 2; + let nonOverflowableId = 3; + for (let i = 0; i < 5; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + if (i != missingId) { + // Setting min-width to make the overflow state not depend on styling of the button and/or + // screen width + let spec = { + id, + type: "button", + removable: true, + label: "test", + tooltiptext: "" + i, + onCreated(node) { + node.style.minWidth = "200px"; + if (id == kTestWidgetPrefix + nonOverflowableId) { + node.setAttribute("overflows", false); + } + }, + }; + info("Creating: " + id); + CustomizableUI.createWidget(spec); + } + } + + let toolbarNode = createOverflowableToolbarWithPlacements( + kToolbarName, + widgetIds + ); + assertAreaPlacements(kToolbarName, widgetIds); + ok( + !toolbarNode.hasAttribute("overflowing"), + "Toolbar shouldn't overflow to start with." + ); + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + toolbarNode.hasAttribute("overflowing") && + !toolbarNode.querySelector("#" + widgetIds[1]) + ); + ok( + toolbarNode.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + let btnId = kTestWidgetPrefix + missingId; + let btn = createDummyXULButton(btnId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(btnId, window); + + is( + btn.parentNode.id, + kToolbarName + "-overflow-list", + "New XUL widget should be placed inside new toolbar's overflow" + ); + is( + btn.previousElementSibling.id, + kTestWidgetPrefix + 1, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + is( + btn.nextElementSibling.id, + kTestWidgetPrefix + 4, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !toolbarNode.hasAttribute("overflowing") + ); + + btn.remove(); + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + removeCustomToolbars(); + await resetCustomization(); +}); + +/* +Tests nodes do *not* get placed in the toolbar's overflow. Replicates a +plcements situation similar to: + +exists-1,trying-to-insert-this,exists-2,overflowed-1 +*/ +add_task(async function () { + let widgetIds = []; + let missingId = 1; + for (let i = 0; i < 5; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + if (i != missingId) { + // Setting min-width to make the overflow state not depend on styling of the button and/or + // screen width + let spec = { + id, + type: "button", + removable: true, + label: "test", + tooltiptext: "" + i, + onCreated(node) { + node.style.minWidth = "200px"; + }, + }; + info("Creating: " + id); + CustomizableUI.createWidget(spec); + } + } + + let toolbarNode = createOverflowableToolbarWithPlacements( + kToolbarName, + widgetIds + ); + assertAreaPlacements(kToolbarName, widgetIds); + ok( + !toolbarNode.hasAttribute("overflowing"), + "Toolbar shouldn't overflow to start with." + ); + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => + toolbarNode.hasAttribute("overflowing") + ); + ok( + toolbarNode.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + let btnId = kTestWidgetPrefix + missingId; + let btn = createDummyXULButton(btnId, "test"); + CustomizableUI.ensureWidgetPlacedInWindow(btnId, window); + + is( + btn.parentNode.id, + kToolbarName + "-target", + "New XUL widget should be placed inside new toolbar" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !toolbarNode.hasAttribute("overflowing") + ); + + btn.remove(); + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + removeCustomToolbars(); + await resetCustomization(); +}); + +/* +Tests inserting a node onto the end of an overflowing toolbar *doesn't* put it in +the overflow list when the widget disallows overflowing. ie: + +exists-1,exists-2,overflows-1,trying-to-insert-this + +Where trying-to-insert-this has overflows=false +*/ +add_task(async function () { + let widgetIds = []; + let missingId = 3; + for (let i = 0; i < 5; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + if (i != missingId) { + // Setting min-width to make the overflow state not depend on styling of the button and/or + // screen width + let spec = { + id, + type: "button", + removable: true, + label: "test", + tooltiptext: "" + i, + onCreated(node) { + node.style.minWidth = "200px"; + }, + }; + info("Creating: " + id); + CustomizableUI.createWidget(spec); + } + } + + let toolbarNode = createOverflowableToolbarWithPlacements( + kToolbarName, + widgetIds + ); + assertAreaPlacements(kToolbarName, widgetIds); + ok( + !toolbarNode.hasAttribute("overflowing"), + "Toolbar shouldn't overflow to start with." + ); + + let originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => + toolbarNode.hasAttribute("overflowing") + ); + ok( + toolbarNode.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + let btnId = kTestWidgetPrefix + missingId; + let btn = createDummyXULButton(btnId, "test"); + btn.setAttribute("overflows", false); + CustomizableUI.ensureWidgetPlacedInWindow(btnId, window); + + is( + btn.parentNode.id, + kToolbarName + "-target", + "New XUL widget should be placed inside new toolbar" + ); + is( + btn.nextElementSibling, + null, + "insertNodeInWindow should have placed new XUL widget in correct place in DOM according to placements" + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !toolbarNode.hasAttribute("overflowing") + ); + + btn.remove(); + widgetIds.forEach(id => CustomizableUI.destroyWidget(id)); + removeCustomToolbars(); + await resetCustomization(); +}); + +add_task(async function asyncCleanUp() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_978084_dragEnd_after_move.js b/browser/components/customizableui/test/browser_978084_dragEnd_after_move.js new file mode 100644 index 0000000000..c818a1b468 --- /dev/null +++ b/browser/components/customizableui/test/browser_978084_dragEnd_after_move.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var draggedItem; + +/** + * Check that customizing-movingItem gets removed on a drop when the item is moved. + */ + +// Drop on the palette +add_task(async function () { + draggedItem = document.createXULElement("toolbarbutton"); + draggedItem.id = "test-dragEnd-after-move1"; + draggedItem.setAttribute("label", "Test"); + draggedItem.setAttribute("removable", "true"); + let navbar = document.getElementById("nav-bar"); + CustomizableUI.getCustomizationTarget(navbar).appendChild(draggedItem); + await startCustomizing(); + simulateItemDrag(draggedItem, gCustomizeMode.visiblePalette); + is( + document.documentElement.hasAttribute("customizing-movingItem"), + false, + "Make sure customizing-movingItem is removed after dragging to the palette" + ); + await endCustomizing(); +}); + +// Drop on a customization target itself +add_task(async function () { + draggedItem = document.createXULElement("toolbarbutton"); + draggedItem.id = "test-dragEnd-after-move2"; + draggedItem.setAttribute("label", "Test"); + draggedItem.setAttribute("removable", "true"); + let dest = createToolbarWithPlacements("test-dragEnd"); + let navbar = document.getElementById("nav-bar"); + CustomizableUI.getCustomizationTarget(navbar).appendChild(draggedItem); + await startCustomizing(); + simulateItemDrag(draggedItem, CustomizableUI.getCustomizationTarget(dest)); + is( + document.documentElement.hasAttribute("customizing-movingItem"), + false, + "Make sure customizing-movingItem is removed" + ); + await endCustomizing(); +}); + +registerCleanupFunction(async function asyncCleanup() { + await endCustomizing(); + removeCustomToolbars(); +}); diff --git a/browser/components/customizableui/test/browser_980155_add_overflow_toolbar.js b/browser/components/customizableui/test/browser_980155_add_overflow_toolbar.js new file mode 100644 index 0000000000..ec80d7dcbc --- /dev/null +++ b/browser/components/customizableui/test/browser_980155_add_overflow_toolbar.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kToolbarName = "test-new-overflowable-toolbar"; +const kTestWidgetPrefix = "test-widget-for-overflowable-toolbar-"; + +add_task(async function addOverflowingToolbar() { + let originalWindowWidth = window.outerWidth; + + let widgetIds = []; + registerCleanupFunction(() => { + try { + for (let id of widgetIds) { + CustomizableUI.destroyWidget(id); + } + } catch (ex) { + console.error(ex); + } + }); + + for (let i = 0; i < 10; i++) { + let id = kTestWidgetPrefix + i; + widgetIds.push(id); + let spec = { + id, + type: "button", + removable: true, + label: "test", + tooltiptext: "" + i, + }; + CustomizableUI.createWidget(spec); + } + + let toolbarNode = createOverflowableToolbarWithPlacements( + kToolbarName, + widgetIds + ); + assertAreaPlacements(kToolbarName, widgetIds); + + for (let id of widgetIds) { + document.getElementById(id).style.minWidth = "200px"; + } + + isnot( + toolbarNode.overflowable, + null, + "Toolbar should have overflowable controller" + ); + isnot( + CustomizableUI.getCustomizationTarget(toolbarNode), + null, + "Toolbar should have customization target" + ); + isnot( + CustomizableUI.getCustomizationTarget(toolbarNode), + toolbarNode, + "Customization target should not be toolbar node" + ); + + let oldChildCount = + CustomizableUI.getCustomizationTarget(toolbarNode).childElementCount; + let overflowableList = document.getElementById( + kToolbarName + "-overflow-list" + ); + let oldOverflowCount = overflowableList.childElementCount; + + isnot(oldChildCount, 0, "Toolbar should have non-overflowing widgets"); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => + toolbarNode.hasAttribute("overflowing") + ); + ok( + toolbarNode.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + Assert.less( + CustomizableUI.getCustomizationTarget(toolbarNode).childElementCount, + oldChildCount, + "Should have fewer children." + ); + Assert.greater( + overflowableList.childElementCount, + oldOverflowCount, + "Should have more overflowed widgets." + ); + + window.resizeTo(originalWindowWidth, window.outerHeight); +}); + +add_task(async function asyncCleanup() { + removeCustomToolbars(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_981305_separator_insertion.js b/browser/components/customizableui/test/browser_981305_separator_insertion.js new file mode 100644 index 0000000000..cce18f33a2 --- /dev/null +++ b/browser/components/customizableui/test/browser_981305_separator_insertion.js @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var tempElements = []; + +function insertTempItemsIntoMenu(parentMenu) { + // Last element is null to insert at the end: + let beforeEls = [ + parentMenu.firstElementChild, + parentMenu.lastElementChild, + null, + ]; + for (let i = 0; i < beforeEls.length; i++) { + let sep = document.createXULElement("menuseparator"); + tempElements.push(sep); + parentMenu.insertBefore(sep, beforeEls[i]); + let menu = document.createXULElement("menu"); + tempElements.push(menu); + parentMenu.insertBefore(menu, beforeEls[i]); + // And another separator for good measure: + sep = document.createXULElement("menuseparator"); + tempElements.push(sep); + parentMenu.insertBefore(sep, beforeEls[i]); + } +} + +async function checkSeparatorInsertion(menuId, buttonId, subviewId) { + info("Checking for duplicate separators in " + buttonId + " widget"); + let menu = document.getElementById(menuId); + insertTempItemsIntoMenu(menu); + + CustomizableUI.addWidgetToArea( + buttonId, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + + let button = document.getElementById(buttonId); + button.click(); + let subview = document.getElementById(subviewId); + await BrowserTestUtils.waitForEvent(subview, "ViewShown"); + + let subviewBody = subview.firstElementChild; + ok(subviewBody.firstElementChild, "Subview should have a kid"); + is( + subviewBody.firstElementChild.localName, + "toolbarbutton", + "There should be no separators to start with" + ); + + for (let kid of subviewBody.children) { + if (kid.localName == "menuseparator") { + ok( + kid.previousElementSibling && + kid.previousElementSibling.localName != "menuseparator", + "Separators should never have another separator next to them, and should never be the first node." + ); + } + } + + let panelHiddenPromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHiddenPromise; + + CustomizableUI.reset(); +} + +add_task(async function check_devtools_separator() { + const panelviewId = "PanelUI-developer-tools"; + + await checkSeparatorInsertion( + "menuWebDeveloperPopup", + "developer-button", + panelviewId + ); +}); + +registerCleanupFunction(function () { + for (let el of tempElements) { + el.remove(); + } + tempElements = null; +}); diff --git a/browser/components/customizableui/test/browser_981418-widget-onbeforecreated-handler.js b/browser/components/customizableui/test/browser_981418-widget-onbeforecreated-handler.js new file mode 100644 index 0000000000..cdcfdc29dc --- /dev/null +++ b/browser/components/customizableui/test/browser_981418-widget-onbeforecreated-handler.js @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; +const kWidgetId = "test-981418-widget-onbeforecreated"; + +// Should be able to add broken view widget +add_task(async function testAddOnBeforeCreatedWidget() { + let onBeforeCreatedCalled = false; + let widgetSpec = { + id: kWidgetId, + type: "view", + viewId: kWidgetId + "idontexistyet", + tooltiptext: "I am an accessible name", + onBeforeCreated(doc) { + let view = doc.createXULElement("panelview"); + view.id = kWidgetId + "idontexistyet"; + document.getElementById("appMenu-viewCache").appendChild(view); + onBeforeCreatedCalled = true; + }, + }; + + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.addWidgetToArea(kWidgetId, CustomizableUI.AREA_NAVBAR); + + ok(onBeforeCreatedCalled, "onBeforeCreated should have been called"); + + let widgetNode = document.getElementById(kWidgetId); + let viewNode = document.getElementById(kWidgetId + "idontexistyet"); + ok(widgetNode, "Widget should exist"); + ok(viewNode, "Panelview should exist"); + + let viewShownPromise = BrowserTestUtils.waitForEvent(viewNode, "ViewShown"); + widgetNode.click(); + await viewShownPromise; + + let widgetPanel = document.getElementById("customizationui-widget-panel"); + ok(widgetPanel, "Widget panel should exist"); + + let panelHiddenPromise = promisePanelElementHidden(window, widgetPanel); + widgetPanel.hidePopup(); + await panelHiddenPromise; + + CustomizableUI.addWidgetToArea( + kWidgetId, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await waitForOverflowButtonShown(); + await document.getElementById("nav-bar").overflowable.show(); + + viewShownPromise = BrowserTestUtils.waitForEvent(viewNode, "ViewShown"); + widgetNode.click(); + await viewShownPromise; + + let panelHidden = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHidden; + + CustomizableUI.destroyWidget(kWidgetId); +}); + +add_task(async function asyncCleanup() { + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_982656_restore_defaults_builtin_widgets.js b/browser/components/customizableui/test/browser_982656_restore_defaults_builtin_widgets.js new file mode 100644 index 0000000000..20314d6790 --- /dev/null +++ b/browser/components/customizableui/test/browser_982656_restore_defaults_builtin_widgets.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Restoring default should not place addon widgets back in the toolbar +add_task(async function () { + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + const kWidgetId = + "bug982656-add-on-widget-should-not-restore-to-default-area"; + let widgetSpec = { + id: kWidgetId, + defaultArea: CustomizableUI.AREA_NAVBAR, + }; + CustomizableUI.createWidget(widgetSpec); + + ok(!CustomizableUI.inDefaultState, "Not in default state after widget added"); + is( + CustomizableUI.getPlacementOfWidget(kWidgetId).area, + CustomizableUI.AREA_NAVBAR, + "Widget should be in navbar" + ); + + await resetCustomization(); + + ok(CustomizableUI.inDefaultState, "Back in default state after reset"); + is( + CustomizableUI.getPlacementOfWidget(kWidgetId), + null, + "Widget now in palette" + ); + CustomizableUI.destroyWidget(kWidgetId); +}); + +// resetCustomization shouldn't move 3rd party widgets out of custom toolbars +add_task(async function () { + const kToolbarId = "bug982656-toolbar-with-defaultset"; + const kWidgetId = + "bug982656-add-on-widget-should-restore-to-default-area-when-area-is-not-builtin"; + ok( + CustomizableUI.inDefaultState, + "Everything should be in its default state." + ); + let toolbar = createToolbarWithPlacements(kToolbarId); + ok(CustomizableUI.areas.includes(kToolbarId), "Toolbar has been registered."); + is( + CustomizableUI.getAreaType(kToolbarId), + CustomizableUI.TYPE_TOOLBAR, + "Area should be registered as toolbar" + ); + + let widgetSpec = { + id: kWidgetId, + defaultArea: kToolbarId, + }; + CustomizableUI.createWidget(widgetSpec); + + ok( + !CustomizableUI.inDefaultState, + "No longer in default state after toolbar is registered and visible." + ); + is( + CustomizableUI.getPlacementOfWidget(kWidgetId).area, + kToolbarId, + "Widget should be in custom toolbar" + ); + + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Back in default state after reset"); + is( + CustomizableUI.getPlacementOfWidget(kWidgetId).area, + kToolbarId, + "Widget still in custom toolbar" + ); + ok(toolbar.collapsed, "Custom toolbar should be collapsed after reset"); + + toolbar.remove(); + CustomizableUI.destroyWidget(kWidgetId); + CustomizableUI.unregisterArea(kToolbarId); +}); diff --git a/browser/components/customizableui/test/browser_984455_bookmarks_items_reparenting.js b/browser/components/customizableui/test/browser_984455_bookmarks_items_reparenting.js new file mode 100644 index 0000000000..9b1d113aa9 --- /dev/null +++ b/browser/components/customizableui/test/browser_984455_bookmarks_items_reparenting.js @@ -0,0 +1,328 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var gNavBar = document.getElementById(CustomizableUI.AREA_NAVBAR); +var gOverflowList = document.getElementById( + gNavBar.getAttribute("default-overflowtarget") +); + +const kBookmarksButton = "bookmarks-menu-button"; +const kBookmarksItems = "personal-bookmarks"; +const kOriginalWindowWidth = window.outerWidth; + +/** + * Helper function that opens the bookmarks menu, and returns a Promise that + * resolves as soon as the menu is ready for interaction. + */ +function bookmarksMenuPanelShown() { + return new Promise(resolve => { + let bookmarksMenuPopup = document.getElementById("BMB_bookmarksPopup"); + let onPopupShown = e => { + if (e.target == bookmarksMenuPopup) { + bookmarksMenuPopup.removeEventListener("popupshown", onPopupShown); + resolve(); + } + }; + bookmarksMenuPopup.addEventListener("popupshown", onPopupShown); + }); +} + +/** + * Checks that the placesContext menu is correctly attached to the + * controller of some view. Returns a Promise that resolves as soon + * as the context menu is closed. + * + * @param aItemWithContextMenu the item that we need to synthesize the + * right click on in order to open the context menu. + */ +function checkPlacesContextMenu(aItemWithContextMenu) { + return (async function () { + let contextMenu = document.getElementById("placesContext"); + let newBookmarkItem = document.getElementById("placesContext_new:bookmark"); + info("Waiting for context menu on " + aItemWithContextMenu.id); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouseAtCenter(aItemWithContextMenu, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + ok( + !newBookmarkItem.hasAttribute("disabled"), + "New bookmark item shouldn't be disabled" + ); + + info("Closing context menu"); + let hiddenPromise = popupHidden(contextMenu); + // Use hidePopup instead of the closePopup helper because macOS native + // context menus can't be closed by synthesized ESC in automation. + contextMenu.hidePopup(); + await hiddenPromise; + })(); +} + +/** + * Opens the bookmarks menu panel, and then opens each of the "special" + * submenus in that list. Then it checks that those submenu's context menus + * are properly hooked up to a controller. + */ +function checkSpecialContextMenus() { + return (async function () { + let bookmarksMenuButton = document.getElementById(kBookmarksButton); + let bookmarksMenuPopup = document.getElementById("BMB_bookmarksPopup"); + + const kSpecialItemIDs = { + BMB_bookmarksToolbar: "BMB_bookmarksToolbarPopup", + BMB_unsortedBookmarks: "BMB_unsortedBookmarksPopup", + }; + + // Open the bookmarks menu button context menus and ensure that + // they have the proper views attached. + let shownPromise = bookmarksMenuPanelShown(); + + EventUtils.synthesizeMouseAtCenter(bookmarksMenuButton, {}); + info("Waiting for bookmarks menu popup to show after clicking dropmarker."); + await shownPromise; + + for (let menuID in kSpecialItemIDs) { + let menuItem = document.getElementById(menuID); + let menuPopup = document.getElementById(kSpecialItemIDs[menuID]); + info("Waiting to open menu for " + menuID); + shownPromise = popupShown(menuPopup); + menuPopup.openPopup(menuItem, null, 0, 0, false, false, null); + await shownPromise; + + await checkPlacesContextMenu(menuPopup); + info("Closing menu for " + menuID); + await closePopup(menuPopup); + } + + info("Closing bookmarks menu"); + await closePopup(bookmarksMenuPopup); + })(); +} + +/** + * Closes a focused popup by simulating pressing the Escape key, + * and returns a Promise that resolves as soon as the popup is closed. + * + * @param aPopup the popup node to close. + */ +function closePopup(aPopup) { + let hiddenPromise = popupHidden(aPopup); + EventUtils.synthesizeKey("KEY_Escape"); + return hiddenPromise; +} + +/** + * Helper function that checks that the context menu of the + * bookmark toolbar items chevron popup is correctly hooked up + * to the controller of a view. + */ +function checkBookmarksItemsChevronContextMenu() { + return (async function () { + let chevronPopup = document.getElementById("PlacesChevronPopup"); + let shownPromise = popupShown(chevronPopup); + let chevron = document.getElementById("PlacesChevron"); + EventUtils.synthesizeMouseAtCenter(chevron, {}); + info("Waiting for bookmark toolbar item chevron popup to show"); + await shownPromise; + await TestUtils.waitForCondition(() => { + for (let child of chevronPopup.children) { + if (child.style.visibility != "hidden") { + return true; + } + } + return false; + }); + await checkPlacesContextMenu(chevronPopup); + info("Waiting for bookmark toolbar item chevron popup to close"); + await closePopup(chevronPopup); + })(); +} + +/** + * Forces the window to a width that causes the nav-bar to overflow + * its contents. Returns a Promise that resolves as soon as the + * overflowable nav-bar is showing its chevron. + */ +function overflowEverything() { + info("Waiting for overflow"); + let waitOverflowing = BrowserTestUtils.waitForMutationCondition( + gNavBar, + { attributes: true, attributeFilter: ["overflowing"] }, + () => gNavBar.hasAttribute("overflowing") + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + return waitOverflowing; +} + +/** + * Returns the window to its original size from the start of the test, + * and returns a Promise that resolves when the nav-bar is no longer + * overflowing. + */ +function stopOverflowing() { + info("Waiting until we stop overflowing"); + let waitOverflowing = BrowserTestUtils.waitForMutationCondition( + gNavBar, + { attributes: true, attributeFilter: ["overflowing"] }, + () => !gNavBar.hasAttribute("overflowing") + ); + window.resizeTo(kOriginalWindowWidth, window.outerHeight); + return waitOverflowing; +} + +/** + * Ensure bookmarks are visible on the toolbar. + * @param {DOMWindow} win the browser window + */ +async function waitBookmarksToolbarIsUpdated(win = window) { + await TestUtils.waitForCondition( + async () => (await win.PlacesToolbarHelper.getIsEmpty()) === false, + "Waiting for the Bookmarks toolbar to have been rebuilt and not be empty" + ); + if ( + win.PlacesToolbarHelper._viewElt._placesView._updateNodesVisibilityTimer + ) { + await BrowserTestUtils.waitForEvent( + win, + "BookmarksToolbarVisibilityUpdated" + ); + } +} + +/** + * Checks that an item with ID aID is overflowing in the nav-bar. + * + * @param aID the ID of the node to check for overflowingness. + */ +function checkOverflowing(aID) { + ok( + !gNavBar.querySelector("#" + aID), + "Item with ID " + aID + " should no longer be in the gNavBar" + ); + let item = gOverflowList.querySelector("#" + aID); + ok(item, "Item with ID " + aID + " should be overflowing"); + is( + item.getAttribute("overflowedItem"), + "true", + "Item with ID " + aID + " should have overflowedItem attribute" + ); +} + +/** + * Checks that an item with ID aID is not overflowing in the nav-bar. + * + * @param aID the ID of hte node to check for non-overflowingness. + */ +function checkNotOverflowing(aID) { + ok( + !gOverflowList.querySelector("#" + aID), + "Item with ID " + aID + " should no longer be overflowing" + ); + let item = gNavBar.querySelector("#" + aID); + ok(item, "Item with ID " + aID + " should be in the nav bar"); + ok( + !item.hasAttribute("overflowedItem"), + "Item with ID " + aID + " should not have overflowedItem attribute" + ); +} + +/** + * Test that overflowing the bookmarks menu button doesn't break the + * context menus for the Unsorted and Bookmarks Toolbar menu items. + */ +add_task(async function testOverflowingBookmarksButtonContextMenu() { + ok(CustomizableUI.inDefaultState, "Should start in default state."); + // The DevEdition has the DevTools button in the toolbar by default. Remove it + // to prevent branch-specific available toolbar space. + CustomizableUI.removeWidgetFromArea("developer-button"); + CustomizableUI.removeWidgetFromArea( + "library-button", + CustomizableUI.AREA_NAVBAR + ); + CustomizableUI.addWidgetToArea(kBookmarksButton, CustomizableUI.AREA_NAVBAR); + ok( + !gNavBar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + + // Open the Unsorted and Bookmarks Toolbar context menus and ensure + // that they have views attached. + await checkSpecialContextMenus(); + + await overflowEverything(); + checkOverflowing(kBookmarksButton); + + await stopOverflowing(); + checkNotOverflowing(kBookmarksButton); + + await checkSpecialContextMenus(); +}); + +/** + * Test that the bookmarks toolbar items context menu still works if moved + * to the menu from the overflow panel, and then back to the toolbar. + */ +add_task(async function testOverflowingBookmarksItemsContextMenu() { + info("Ensuring panel is ready."); + await PanelUI.ensureReady(); + + let bookmarksToolbarItems = document.getElementById(kBookmarksItems); + await gCustomizeMode.addToToolbar(bookmarksToolbarItems); + await waitBookmarksToolbarIsUpdated(); + await checkPlacesContextMenu(bookmarksToolbarItems); + + await overflowEverything(); + checkOverflowing(kBookmarksItems); + + await gCustomizeMode.addToPanel(bookmarksToolbarItems); + + await stopOverflowing(); + + await gCustomizeMode.addToToolbar(bookmarksToolbarItems); + await waitBookmarksToolbarIsUpdated(); + await checkPlacesContextMenu(bookmarksToolbarItems); +}); + +/** + * Test that overflowing the bookmarks toolbar items doesn't cause the + * context menu in the bookmarks toolbar items chevron to stop working. + */ +add_task(async function testOverflowingBookmarksItemsChevronContextMenu() { + // If it's not already there, let's move the bookmarks toolbar items to + // the nav-bar. + let bookmarksToolbarItems = document.getElementById(kBookmarksItems); + await gCustomizeMode.addToToolbar(bookmarksToolbarItems); + + // We make the PlacesToolbarItems element be super tiny in order to force + // the bookmarks toolbar items into overflowing and making the chevron + // show itself. + let placesToolbarItems = document.getElementById("PlacesToolbarItems"); + let placesChevron = document.getElementById("PlacesChevron"); + placesToolbarItems.style.maxWidth = "10px"; + info("Waiting for chevron to no longer be collapsed"); + await TestUtils.waitForCondition(() => !placesChevron.collapsed); + + await checkBookmarksItemsChevronContextMenu(); + + await overflowEverything(); + checkOverflowing(kBookmarksItems); + + await stopOverflowing(); + checkNotOverflowing(kBookmarksItems); + + await waitBookmarksToolbarIsUpdated(); + await checkBookmarksItemsChevronContextMenu(); + + placesToolbarItems.style.removeProperty("max-width"); +}); + +add_task(async function asyncCleanup() { + window.resizeTo(kOriginalWindowWidth, window.outerHeight); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_985815_propagate_setToolbarVisibility.js b/browser/components/customizableui/test/browser_985815_propagate_setToolbarVisibility.js new file mode 100644 index 0000000000..3b2bd13731 --- /dev/null +++ b/browser/components/customizableui/test/browser_985815_propagate_setToolbarVisibility.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + ok(CustomizableUI.inDefaultState, "Should start in default state."); + this.otherWin = await openAndLoadWindow({ private: true }, true); + await startCustomizing(this.otherWin); + let resetButton = this.otherWin.document.getElementById( + "customization-reset-button" + ); + ok(resetButton.disabled, "Reset button should be disabled"); + + if (typeof CustomizableUI.setToolbarVisibility == "function") { + CustomizableUI.setToolbarVisibility("PersonalToolbar", true); + } else { + setToolbarVisibility(document.getElementById("PersonalToolbar"), true); + } + + let otherPersonalToolbar = + this.otherWin.document.getElementById("PersonalToolbar"); + let personalToolbar = document.getElementById("PersonalToolbar"); + ok( + !otherPersonalToolbar.collapsed, + "Toolbar should be uncollapsed in private window" + ); + ok( + !personalToolbar.collapsed, + "Toolbar should be uncollapsed in normal window" + ); + ok(!resetButton.disabled, "Reset button should be enabled"); + + await this.otherWin.gCustomizeMode.reset(); + + ok( + otherPersonalToolbar.collapsed, + "Toolbar should be collapsed in private window" + ); + ok(personalToolbar.collapsed, "Toolbar should be collapsed in normal window"); + ok(resetButton.disabled, "Reset button should be disabled"); + + await endCustomizing(this.otherWin); + + await promiseWindowClosed(this.otherWin); +}); + +add_task(async function asyncCleanup() { + if (this.otherWin && !this.otherWin.closed) { + await promiseWindowClosed(this.otherWin); + } + if (!CustomizableUI.inDefaultState) { + CustomizableUI.reset(); + } +}); diff --git a/browser/components/customizableui/test/browser_987177_destroyWidget_xul.js b/browser/components/customizableui/test/browser_987177_destroyWidget_xul.js new file mode 100644 index 0000000000..5881011b85 --- /dev/null +++ b/browser/components/customizableui/test/browser_987177_destroyWidget_xul.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const BUTTONID = "test-XUL-wrapper-destroyWidget"; + +add_task(function () { + let btn = createDummyXULButton(BUTTONID, "XUL btn"); + gNavToolbox.palette.appendChild(btn); + let firstWrapper = CustomizableUI.getWidget(BUTTONID).forWindow(window); + ok(firstWrapper, "Should get a wrapper"); + ok(firstWrapper.node, "Node should be there on first wrapper."); + + btn.remove(); + CustomizableUI.destroyWidget(BUTTONID); + let secondWrapper = CustomizableUI.getWidget(BUTTONID).forWindow(window); + isnot( + firstWrapper, + secondWrapper, + "Wrappers should be different after destroyWidget call." + ); + ok(!firstWrapper.node, "No node should be there on old wrapper."); + ok(!secondWrapper.node, "No node should be there on new wrapper."); + + btn = createDummyXULButton(BUTTONID, "XUL btn"); + gNavToolbox.palette.appendChild(btn); + let thirdWrapper = CustomizableUI.getWidget(BUTTONID).forWindow(window); + ok(thirdWrapper, "Should get a wrapper"); + is(secondWrapper, thirdWrapper, "Should get the second wrapper again."); + ok(firstWrapper.node, "Node should be there on old wrapper."); + ok(secondWrapper.node, "Node should be there on second wrapper."); + ok(thirdWrapper.node, "Node should be there on third wrapper."); +}); diff --git a/browser/components/customizableui/test/browser_987177_xul_wrapper_updating.js b/browser/components/customizableui/test/browser_987177_xul_wrapper_updating.js new file mode 100644 index 0000000000..9ef22c4e1b --- /dev/null +++ b/browser/components/customizableui/test/browser_987177_xul_wrapper_updating.js @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const BUTTONID = "test-XUL-wrapper-widget"; +add_task(function () { + let btn = createDummyXULButton(BUTTONID, "XUL btn"); + gNavToolbox.palette.appendChild(btn); + let groupWrapper = CustomizableUI.getWidget(BUTTONID); + ok(groupWrapper, "Should get a group wrapper"); + let singleWrapper = groupWrapper.forWindow(window); + ok(singleWrapper, "Should get a single wrapper"); + is(singleWrapper.node, btn, "Node should be in the wrapper"); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, btn, "Button should be that instance."); + + CustomizableUI.addWidgetToArea(BUTTONID, CustomizableUI.AREA_NAVBAR); + + let otherSingleWrapper = groupWrapper.forWindow(window); + is( + singleWrapper, + otherSingleWrapper, + "Should get the same wrapper after adding the node to the navbar." + ); + is(singleWrapper.node, btn, "Node should be in the wrapper"); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, btn, "Button should be that instance."); + + CustomizableUI.removeWidgetFromArea(BUTTONID); + + otherSingleWrapper = groupWrapper.forWindow(window); + isnot( + singleWrapper, + otherSingleWrapper, + "Shouldn't get the same wrapper after removing it from the navbar." + ); + singleWrapper = otherSingleWrapper; + is(singleWrapper.node, btn, "Node should be in the wrapper"); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, btn, "Button should be that instance."); + + btn.remove(); + otherSingleWrapper = groupWrapper.forWindow(window); + is( + singleWrapper, + otherSingleWrapper, + "Should get the same wrapper after physically removing the node." + ); + is( + singleWrapper.node, + null, + "Wrapper's node should be null now that it's left the DOM." + ); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, null, "That instance should be null."); + + btn = createDummyXULButton(BUTTONID, "XUL btn"); + gNavToolbox.palette.appendChild(btn); + otherSingleWrapper = groupWrapper.forWindow(window); + is( + singleWrapper, + otherSingleWrapper, + "Should get the same wrapper after readding the node." + ); + is(singleWrapper.node, btn, "Node should be in the wrapper"); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, btn, "Button should be that instance."); + + CustomizableUI.addWidgetToArea(BUTTONID, CustomizableUI.AREA_NAVBAR); + + otherSingleWrapper = groupWrapper.forWindow(window); + is( + singleWrapper, + otherSingleWrapper, + "Should get the same wrapper after adding the node to the navbar." + ); + is(singleWrapper.node, btn, "Node should be in the wrapper"); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, btn, "Button should be that instance."); + + CustomizableUI.removeWidgetFromArea(BUTTONID); + + otherSingleWrapper = groupWrapper.forWindow(window); + isnot( + singleWrapper, + otherSingleWrapper, + "Shouldn't get the same wrapper after removing it from the navbar." + ); + singleWrapper = otherSingleWrapper; + is(singleWrapper.node, btn, "Node should be in the wrapper"); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, btn, "Button should be that instance."); + + btn.remove(); + otherSingleWrapper = groupWrapper.forWindow(window); + is( + singleWrapper, + otherSingleWrapper, + "Should get the same wrapper after physically removing the node." + ); + is( + singleWrapper.node, + null, + "Wrapper's node should be null now that it's left the DOM." + ); + is( + groupWrapper.instances.length, + 1, + "There should be 1 instance on the group wrapper" + ); + is(groupWrapper.instances[0].node, null, "That instance should be null."); +}); diff --git a/browser/components/customizableui/test/browser_987492_window_api.js b/browser/components/customizableui/test/browser_987492_window_api.js new file mode 100644 index 0000000000..5e69573d60 --- /dev/null +++ b/browser/components/customizableui/test/browser_987492_window_api.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function testOneWindow() { + let windows = []; + for (let win of CustomizableUI.windows) { + windows.push(win); + } + is(windows.length, 1, "Should have one customizable window"); +}); + +add_task(async function testOpenCloseWindow() { + let newWindow = null; + let openListener = { + onWindowOpened(window) { + newWindow = window; + }, + }; + CustomizableUI.addListener(openListener); + + { + let win = await openAndLoadWindow(null, true); + is( + newWindow, + win, + "onWindowOpen event should have received expected window" + ); + isnot(newWindow, null, "Should have gotten onWindowOpen event"); + } + + CustomizableUI.removeListener(openListener); + + let windows = []; + for (let win of CustomizableUI.windows) { + windows.push(win); + } + is(windows.length, 2, "Should have two customizable windows"); + isnot( + windows.indexOf(window), + -1, + "Current window should be in window collection." + ); + isnot( + windows.indexOf(newWindow), + -1, + "New window should be in window collection." + ); + + let closedWindow = null; + let closeListener = { + onWindowClosed(window) { + closedWindow = window; + }, + }; + CustomizableUI.addListener(closeListener); + await promiseWindowClosed(newWindow); + isnot(closedWindow, null, "Should have gotten onWindowClosed event"); + is( + newWindow, + closedWindow, + "Closed window should match previously opened window" + ); + CustomizableUI.removeListener(closeListener); + + windows = []; + for (let win of CustomizableUI.windows) { + windows.push(win); + } + is(windows.length, 1, "Should have one customizable window"); + isnot( + windows.indexOf(window), + -1, + "Current window should be in window collection." + ); + is( + windows.indexOf(closedWindow), + -1, + "Closed window should not be in window collection." + ); +}); diff --git a/browser/components/customizableui/test/browser_987640_charEncoding.js b/browser/components/customizableui/test/browser_987640_charEncoding.js new file mode 100644 index 0000000000..65e38a0b85 --- /dev/null +++ b/browser/components/customizableui/test/browser_987640_charEncoding.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_PAGE = + "http://mochi.test:8888/browser/browser/components/customizableui/test/support/test_967000_charEncoding_page.html"; + +add_task(async function () { + info("Check Character Encoding panel functionality"); + + // add the Character Encoding button to the panel + CustomizableUI.addWidgetToArea( + "characterencoding-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE, + true, + true + ); + + await document.getElementById("nav-bar").overflowable.show(); + let charEncodingButton = document.getElementById("characterencoding-button"); + + ok( + !charEncodingButton.hasAttribute("disabled"), + "The encoding button should be enabled" + ); + + let browserStopPromise = BrowserTestUtils.browserStopped(gBrowser, TEST_PAGE); + charEncodingButton.click(); + await browserStopPromise; + is( + gBrowser.selectedBrowser.characterSet, + "UTF-8", + "The encoding should be changed to UTF-8" + ); + ok( + !gBrowser.selectedBrowser.mayEnableCharacterEncodingMenu, + "The encoding menu should be disabled" + ); + + is( + charEncodingButton.getAttribute("disabled"), + "true", + "We should disable the encoding button in toolbar" + ); + + CustomizableUI.removeWidgetFromArea("characterencoding-button"); + CustomizableUI.addWidgetToArea( + "characterencoding-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await waitForOverflowButtonShown(); + await document.getElementById("nav-bar").overflowable.show(); + charEncodingButton = document.getElementById("characterencoding-button"); + + // check the encoding menu again + is( + charEncodingButton.getAttribute("disabled"), + "true", + "We should disable the encoding button in overflow menu" + ); + + BrowserTestUtils.removeTab(newTab); +}); + +add_task(async function asyncCleanup() { + // reset the panel to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The UI is in default state again."); +}); diff --git a/browser/components/customizableui/test/browser_989338_saved_placements_not_resaved.js b/browser/components/customizableui/test/browser_989338_saved_placements_not_resaved.js new file mode 100644 index 0000000000..d2b87a7a31 --- /dev/null +++ b/browser/components/customizableui/test/browser_989338_saved_placements_not_resaved.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const BUTTONID = "test-widget-saved-earlier"; +const AREAID = "test-area-saved-earlier"; + +var hadSavedState; +function test() { + let gSavedState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + hadSavedState = gSavedState != null; + if (!hadSavedState) { + gSavedState = { placements: {} }; + CustomizableUI.setTestOnlyInternalProp("gSavedState", gSavedState); + } + gSavedState.placements[AREAID] = [BUTTONID]; + // Put bogus stuff in the saved state for the nav-bar, so as to check the current placements + // override this one... + gSavedState.placements[CustomizableUI.AREA_NAVBAR] = ["bogus-navbar-item"]; + + CustomizableUI.setTestOnlyInternalProp("gDirty", true); + CustomizableUI.getTestOnlyInternalProp("CustomizableUIInternal").saveState(); + + let newSavedState = JSON.parse( + Services.prefs.getCharPref("browser.uiCustomization.state") + ); + let savedArea = Array.isArray(newSavedState.placements[AREAID]); + ok( + savedArea, + "Should have re-saved the state, even though the area isn't registered" + ); + + if (savedArea) { + placementArraysEqual(AREAID, newSavedState.placements[AREAID], [BUTTONID]); + } + ok( + !CustomizableUI.getTestOnlyInternalProp("gPlacements").has(AREAID), + "Placements map shouldn't have been affected" + ); + + let savedNavbar = Array.isArray( + newSavedState.placements[CustomizableUI.AREA_NAVBAR] + ); + ok(savedNavbar, "Should have saved nav-bar contents"); + if (savedNavbar) { + placementArraysEqual( + CustomizableUI.AREA_NAVBAR, + newSavedState.placements[CustomizableUI.AREA_NAVBAR], + CustomizableUI.getWidgetIdsInArea(CustomizableUI.AREA_NAVBAR) + ); + } +} + +registerCleanupFunction(function () { + if (!hadSavedState) { + CustomizableUI.setTestOnlyInternalProp("gSavedState", null); + } else { + let gSavedState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + let savedPlacements = gSavedState.placements; + delete savedPlacements[AREAID]; + let realNavBarPlacements = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_NAVBAR + ); + savedPlacements[CustomizableUI.AREA_NAVBAR] = realNavBarPlacements; + } + CustomizableUI.setTestOnlyInternalProp("gDirty", true); + CustomizableUI.getTestOnlyInternalProp("CustomizableUIInternal").saveState(); +}); diff --git a/browser/components/customizableui/test/browser_989751_subviewbutton_class.js b/browser/components/customizableui/test/browser_989751_subviewbutton_class.js new file mode 100644 index 0000000000..d97017cfcd --- /dev/null +++ b/browser/components/customizableui/test/browser_989751_subviewbutton_class.js @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kCustomClass = "acustomclassnoonewilluse"; +const kDevPanelId = "PanelUI-developer-tools"; +var tempElement = null; + +function insertClassNameToMenuChildren(parentMenu) { + // Skip hidden menuitem elements, not copied via fillSubviewFromMenuItems. + let el = parentMenu.querySelector("menuitem:not([hidden])"); + el.classList.add(kCustomClass); + tempElement = el; +} + +function checkSubviewButtonClass(menuId, buttonId, subviewId) { + return async function () { + // Initialize DevTools before starting the test in order to create menuitems in + // menuWebDeveloperPopup. + ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ).require("devtools/client/framework/devtools-browser"); + + info( + "Checking for items without the subviewbutton class in " + + buttonId + + " widget" + ); + let menu = document.getElementById(menuId); + insertClassNameToMenuChildren(menu); + + CustomizableUI.addWidgetToArea( + buttonId, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + await document.getElementById("nav-bar").overflowable.show(); + + let button = document.getElementById(buttonId); + button.click(); + + await BrowserTestUtils.waitForEvent(PanelUI.overflowPanel, "ViewShown"); + let subview = document.getElementById(subviewId); + ok(subview.firstElementChild, "Subview should have a kid"); + + // The Developer Panel contains the Customize Toolbar item, + // as well as the Developer Tools items (bug 1703150). We only want to query for + // the Developer Tools items in this case. + let query = "#appmenu-developer-tools-view toolbarbutton"; + let subviewchildren = subview.querySelectorAll(query); + + for (let i = 0; i < subviewchildren.length; i++) { + let item = subviewchildren[i]; + let itemReadable = + "Item '" + item.label + "' (classes: " + item.className + ")"; + ok( + item.classList.contains("subviewbutton"), + itemReadable + " should have the subviewbutton class." + ); + if (i == 0) { + ok( + item.classList.contains(kCustomClass), + itemReadable + " should still have its own class, too." + ); + } + } + + let panelHiddenPromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHiddenPromise; + + CustomizableUI.reset(); + }; +} + +add_task( + checkSubviewButtonClass( + "menuWebDeveloperPopup", + "developer-button", + kDevPanelId + ) +); + +registerCleanupFunction(function () { + tempElement.classList.remove(kCustomClass); + tempElement = null; +}); diff --git a/browser/components/customizableui/test/browser_992747_toggle_noncustomizable_toolbar.js b/browser/components/customizableui/test/browser_992747_toggle_noncustomizable_toolbar.js new file mode 100644 index 0000000000..d89845f03b --- /dev/null +++ b/browser/components/customizableui/test/browser_992747_toggle_noncustomizable_toolbar.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TOOLBARID = "test-noncustomizable-toolbar-for-toggling"; +function test() { + let tb = document.createXULElement("toolbar"); + tb.id = TOOLBARID; + gNavToolbox.appendChild(tb); + try { + CustomizableUI.setToolbarVisibility(TOOLBARID, false); + } catch (ex) { + ok(false, "Should not throw exceptions trying to set toolbar visibility."); + } + is(tb.getAttribute("collapsed"), "true", "Toolbar should be collapsed"); + try { + CustomizableUI.setToolbarVisibility(TOOLBARID, true); + } catch (ex) { + ok(false, "Should not throw exceptions trying to set toolbar visibility."); + } + is(tb.getAttribute("collapsed"), "false", "Toolbar should be uncollapsed"); + tb.remove(); +} diff --git a/browser/components/customizableui/test/browser_993322_widget_notoolbar.js b/browser/components/customizableui/test/browser_993322_widget_notoolbar.js new file mode 100644 index 0000000000..5e6cc65585 --- /dev/null +++ b/browser/components/customizableui/test/browser_993322_widget_notoolbar.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const BUTTONID = "test-API-created-widget-toolbar-gone"; +const TOOLBARID = "test-API-created-extra-toolbar"; + +add_task(async function () { + let toolbar = createToolbarWithPlacements(TOOLBARID, []); + CustomizableUI.addWidgetToArea(BUTTONID, TOOLBARID); + is( + CustomizableUI.getPlacementOfWidget(BUTTONID).area, + TOOLBARID, + "Should be on toolbar" + ); + is(toolbar.children.length, 0, "Toolbar has no kid"); + + CustomizableUI.unregisterArea(TOOLBARID); + CustomizableUI.createWidget({ + id: BUTTONID, + label: "Test widget toolbar gone", + }); + + let currentWidget = CustomizableUI.getWidget(BUTTONID); + + await startCustomizing(); + let buttonNode = document.getElementById(BUTTONID); + ok(buttonNode, "Should find button in window"); + if (buttonNode) { + is( + buttonNode.parentNode.localName, + "toolbarpaletteitem", + "Node should be wrapped" + ); + is( + buttonNode.parentNode.getAttribute("place"), + "palette", + "Node should be in palette" + ); + is( + buttonNode, + gNavToolbox.palette.querySelector("#" + BUTTONID), + "Node should really be in palette." + ); + } + is( + currentWidget.forWindow(window).node, + buttonNode, + "Should have the same node for customize mode" + ); + await endCustomizing(); + + CustomizableUI.destroyWidget(BUTTONID); + CustomizableUI.unregisterArea(TOOLBARID, true); + toolbar.remove(); + gAddedToolbars.clear(); +}); diff --git a/browser/components/customizableui/test/browser_995164_registerArea_during_customize_mode.js b/browser/components/customizableui/test/browser_995164_registerArea_during_customize_mode.js new file mode 100644 index 0000000000..8829611083 --- /dev/null +++ b/browser/components/customizableui/test/browser_995164_registerArea_during_customize_mode.js @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TOOLBARID = "test-toolbar-added-during-customize-mode"; + +// The ID of a button that is not placed (ie, is in the palette) by default +const kNonPlacedWidgetId = "open-file-button"; + +add_task(async function () { + await startCustomizing(); + let toolbar = createToolbarWithPlacements(TOOLBARID, []); + CustomizableUI.addWidgetToArea(kNonPlacedWidgetId, TOOLBARID); + let button = document.getElementById(kNonPlacedWidgetId); + ok(button, "Button should exist."); + is( + button.parentNode.localName, + "toolbarpaletteitem", + "Button's parent node should be a wrapper." + ); + + simulateItemDrag(button, gNavToolbox.palette); + ok( + !CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId), + "Button moved to the palette" + ); + ok( + gNavToolbox.palette.querySelector(`#${kNonPlacedWidgetId}`), + "Button really is in palette." + ); + + button.scrollIntoView(); + simulateItemDrag(button, toolbar); + ok( + CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId), + "Button moved out of palette" + ); + is( + CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId).area, + TOOLBARID, + "Button's back on toolbar" + ); + ok( + toolbar.querySelector(`#${kNonPlacedWidgetId}`), + "Button really is on toolbar." + ); + + await endCustomizing(); + isnot( + button.parentNode.localName, + "toolbarpaletteitem", + "Button's parent node should not be a wrapper outside customize mode." + ); + await startCustomizing(); + + is( + button.parentNode.localName, + "toolbarpaletteitem", + "Button's parent node should be a wrapper back in customize mode." + ); + + simulateItemDrag(button, gNavToolbox.palette); + ok( + !CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId), + "Button moved to the palette" + ); + ok( + gNavToolbox.palette.querySelector(`#${kNonPlacedWidgetId}`), + "Button really is in palette." + ); + + ok( + !CustomizableUI.inDefaultState, + "Not in default state while toolbar is not collapsed yet." + ); + setToolbarVisibility(toolbar, false); + ok( + CustomizableUI.inDefaultState, + "In default state while toolbar is collapsed." + ); + + setToolbarVisibility(toolbar, true); + + info( + "Check that removing the area registration from within customize mode works" + ); + CustomizableUI.unregisterArea(TOOLBARID); + ok( + CustomizableUI.inDefaultState, + "Now that the toolbar is no longer registered, should be in default state." + ); + ok( + !gCustomizeMode.areas.has(toolbar), + "Toolbar shouldn't be known to customize mode." + ); + + CustomizableUI.registerArea(TOOLBARID, { defaultPlacements: [] }); + CustomizableUI.registerToolbarNode(toolbar, []); + ok( + !CustomizableUI.inDefaultState, + "Now that the toolbar is registered again, should no longer be in default state." + ); + ok( + gCustomizeMode.areas.has(toolbar), + "Toolbar should be known to customize mode again." + ); + + button.scrollIntoView(); + simulateItemDrag(button, toolbar); + ok( + CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId), + "Button moved out of palette" + ); + is( + CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId).area, + TOOLBARID, + "Button's back on toolbar" + ); + ok( + toolbar.querySelector(`#${kNonPlacedWidgetId}`), + "Button really is on toolbar." + ); + + let otherWin = await openAndLoadWindow({}, true); + let otherTB = otherWin.document.createXULElement("toolbar"); + otherTB.id = TOOLBARID; + otherTB.setAttribute("customizable", "true"); + let wasInformedCorrectlyOfAreaAppearing = false; + let listener = { + onAreaNodeRegistered(aArea, aNode) { + if (aNode == otherTB) { + wasInformedCorrectlyOfAreaAppearing = true; + } + }, + }; + CustomizableUI.addListener(listener); + otherWin.gNavToolbox.appendChild(otherTB); + CustomizableUI.registerToolbarNode(otherTB); + ok( + wasInformedCorrectlyOfAreaAppearing, + "Should have been told area was registered." + ); + CustomizableUI.removeListener(listener); + + ok( + otherTB.querySelector(`#${kNonPlacedWidgetId}`), + "Button is on other toolbar, too." + ); + + simulateItemDrag(button, gNavToolbox.palette); + ok( + !CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId), + "Button moved to the palette" + ); + ok( + gNavToolbox.palette.querySelector(`#${kNonPlacedWidgetId}`), + "Button really is in palette." + ); + ok( + !otherTB.querySelector(`#${kNonPlacedWidgetId}`), + "Button is in palette in other window, too." + ); + + button.scrollIntoView(); + simulateItemDrag(button, toolbar); + ok( + CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId), + "Button moved out of palette" + ); + is( + CustomizableUI.getPlacementOfWidget(kNonPlacedWidgetId).area, + TOOLBARID, + "Button's back on toolbar" + ); + ok( + toolbar.querySelector(`#${kNonPlacedWidgetId}`), + "Button really is on toolbar." + ); + ok( + otherTB.querySelector(`#${kNonPlacedWidgetId}`), + "Button is on other toolbar, too." + ); + + let wasInformedCorrectlyOfAreaDisappearing = false; + // XXXgijs So we could be using promiseWindowClosed here. However, after + // repeated random oranges, I'm instead relying on onWindowClosed below to + // fire appropriately - it is linked to an unload event as well, and so + // reusing it prevents a potential race between unload handlers where the + // one from promiseWindowClosed could fire before the onWindowClosed + // (and therefore onAreaNodeRegistered) one, causing the test to fail. + let windowClosed = await new Promise(resolve => { + listener = { + onAreaNodeUnregistered(aArea, aNode, aReason) { + if (aArea == TOOLBARID) { + is(aNode, otherTB, "Should be informed about other toolbar"); + is( + aReason, + CustomizableUI.REASON_WINDOW_CLOSED, + "Reason should be correct." + ); + wasInformedCorrectlyOfAreaDisappearing = + aReason === CustomizableUI.REASON_WINDOW_CLOSED; + } + }, + onWindowClosed(aWindow) { + if (aWindow == otherWin) { + resolve(aWindow); + } else { + info("Other window was closed!"); + info( + "Other window title: " + + (aWindow.document && aWindow.document.title) + ); + info( + "Our window title: " + + (otherWin.document && otherWin.document.title) + ); + } + }, + }; + CustomizableUI.addListener(listener); + otherWin.close(); + }); + + is( + windowClosed, + otherWin, + "Window should have sent onWindowClosed notification." + ); + ok( + wasInformedCorrectlyOfAreaDisappearing, + "Should be told about window closing." + ); + // Closing the other window should not be counted against this window's customize mode: + is( + button.parentNode.localName, + "toolbarpaletteitem", + "Button's parent node should still be a wrapper." + ); + ok( + gCustomizeMode.areas.has(toolbar), + "Toolbar should still be a customizable area for this customize mode instance." + ); + + await gCustomizeMode.reset(); + + await endCustomizing(); + + CustomizableUI.removeListener(listener); + wasInformedCorrectlyOfAreaDisappearing = false; + listener = { + onAreaNodeUnregistered(aArea, aNode, aReason) { + if (aArea == TOOLBARID) { + is(aNode, toolbar, "Should be informed about this window's toolbar"); + is( + aReason, + CustomizableUI.REASON_AREA_UNREGISTERED, + "Reason for final removal should be correct." + ); + wasInformedCorrectlyOfAreaDisappearing = + aReason === CustomizableUI.REASON_AREA_UNREGISTERED; + } + }, + }; + CustomizableUI.addListener(listener); + removeCustomToolbars(); + ok( + wasInformedCorrectlyOfAreaDisappearing, + "Should be told about area being unregistered." + ); + CustomizableUI.removeListener(listener); + ok( + CustomizableUI.inDefaultState, + "Should be fine after exiting customize mode." + ); +}); diff --git a/browser/components/customizableui/test/browser_996364_registerArea_different_properties.js b/browser/components/customizableui/test/browser_996364_registerArea_different_properties.js new file mode 100644 index 0000000000..7d8ea59150 --- /dev/null +++ b/browser/components/customizableui/test/browser_996364_registerArea_different_properties.js @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Calling CustomizableUI.registerArea twice with no +// properties should not throw an exception. +add_task(function () { + try { + CustomizableUI.registerArea("area-996364", {}); + CustomizableUI.registerArea("area-996364", {}); + } catch (ex) { + ok(false, ex.message); + } + + CustomizableUI.unregisterArea("area-996364", true); +}); + +add_task(function () { + let exceptionThrown = false; + try { + CustomizableUI.registerArea("area-996364-2", { + type: CustomizableUI.TYPE_TOOLBAR, + defaultCollapsed: "false", + }); + } catch (ex) { + exceptionThrown = true; + } + ok( + exceptionThrown, + "defaultCollapsed is not allowed as an external property" + ); + + // No need to unregister the area because registration fails. +}); + +add_task(function () { + let exceptionThrown; + try { + CustomizableUI.registerArea("area-996364-3", { + type: CustomizableUI.TYPE_TOOLBAR, + }); + CustomizableUI.registerArea("area-996364-3", { + type: CustomizableUI.TYPE_PANEL, + }); + } catch (ex) { + exceptionThrown = ex; + } + ok( + exceptionThrown, + "Exception expected, an area cannot change types: " + + (exceptionThrown ? exceptionThrown : "[no exception]") + ); + + CustomizableUI.unregisterArea("area-996364-3", true); +}); + +add_task(function () { + let exceptionThrown; + try { + CustomizableUI.registerArea("area-996364-4", { + type: CustomizableUI.TYPE_PANEL, + }); + CustomizableUI.registerArea("area-996364-4", { + type: CustomizableUI.TYPE_TOOLBAR, + }); + } catch (ex) { + exceptionThrown = ex; + } + ok( + exceptionThrown, + "Exception expected, an area cannot change types: " + + (exceptionThrown ? exceptionThrown : "[no exception]") + ); + + CustomizableUI.unregisterArea("area-996364-4", true); +}); + +add_task(function () { + let exceptionThrown; + try { + CustomizableUI.registerArea("area-996899-1", { + anchor: "PanelUI-menu-button", + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + }); + CustomizableUI.registerArea("area-996899-1", { + anchor: "home-button", + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + }); + } catch (ex) { + exceptionThrown = ex; + } + ok( + !exceptionThrown, + "Changing anchors shouldn't throw an exception: " + + (exceptionThrown ? exceptionThrown : "[no exception]") + ); + CustomizableUI.unregisterArea("area-996899-1", true); +}); + +add_task(function () { + let exceptionThrown; + try { + CustomizableUI.registerArea("area-996899-2", { + anchor: "PanelUI-menu-button", + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + }); + CustomizableUI.registerArea("area-996899-2", { + anchor: "PanelUI-menu-button", + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: ["new-window-button"], + }); + } catch (ex) { + exceptionThrown = ex; + } + ok( + !exceptionThrown, + "Changing defaultPlacements shouldn't throw an exception: " + + (exceptionThrown ? exceptionThrown : "[no exception]") + ); + CustomizableUI.unregisterArea("area-996899-2", true); +}); + +add_task(function () { + let exceptionThrown; + try { + CustomizableUI.registerArea("area-996899-4", { overflowable: true }); + CustomizableUI.registerArea("area-996899-4", { overflowable: false }); + } catch (ex) { + exceptionThrown = ex; + } + ok( + exceptionThrown, + "Changing 'overflowable' should throw an exception: " + + (exceptionThrown ? exceptionThrown : "[no exception]") + ); + CustomizableUI.unregisterArea("area-996899-4", true); +}); diff --git a/browser/components/customizableui/test/browser_996635_remove_non_widgets.js b/browser/components/customizableui/test/browser_996635_remove_non_widgets.js new file mode 100644 index 0000000000..80f68433e8 --- /dev/null +++ b/browser/components/customizableui/test/browser_996635_remove_non_widgets.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// NB: This is testing what happens if something that /isn't/ a customizable +// widget gets used in CustomizableUI APIs. Don't use this as an example of +// what should happen in a "normal" case or how you should use the API. +function test() { + // First create a button that isn't customizable, and add it in the nav-bar, + // but not in the customizable part of it (the customization target) but + // next to the main (hamburger) menu button. + const buttonID = "Test-non-widget-non-removable-button"; + let btn = document.createXULElement("toolbarbutton"); + btn.id = buttonID; + btn.label = "Hi"; + btn.setAttribute("style", "width: 20px; height: 20px; background-color: red"); + document.getElementById("nav-bar").appendChild(btn); + registerCleanupFunction(function () { + btn.remove(); + }); + + // Now try to add this non-customizable button to the tabstrip. This will + // update the internal bookkeeping (ie placements) information, but shouldn't + // move the node. + CustomizableUI.addWidgetToArea(buttonID, CustomizableUI.AREA_TABSTRIP); + let placement = CustomizableUI.getPlacementOfWidget(buttonID); + // Check our bookkeeping + ok(placement, "Button should be placed"); + is( + placement && placement.area, + CustomizableUI.AREA_TABSTRIP, + "Should be placed on tabstrip." + ); + // Check we didn't move the node. + is( + btn.parentNode && btn.parentNode.id, + "nav-bar", + "Actual button should still be on navbar." + ); + + // Now remove the node again. This should remove the bookkeeping, but again + // not affect the actual node. + CustomizableUI.removeWidgetFromArea(buttonID); + placement = CustomizableUI.getPlacementOfWidget(buttonID); + // Check our bookkeeping: + ok(!placement, "Button should no longer have a placement."); + // Check our node. + is( + btn.parentNode && btn.parentNode.id, + "nav-bar", + "Actual button should still be on navbar." + ); +} diff --git a/browser/components/customizableui/test/browser_PanelMultiView.js b/browser/components/customizableui/test/browser_PanelMultiView.js new file mode 100644 index 0000000000..80778b94b0 --- /dev/null +++ b/browser/components/customizableui/test/browser_PanelMultiView.js @@ -0,0 +1,566 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Unit tests for the PanelMultiView module. + */ + +const PANELS_COUNT = 2; +let gPanelAnchors = []; +let gPanels = []; +let gPanelMultiViews = []; + +const PANELVIEWS_COUNT = 4; +let gPanelViews = []; +let gPanelViewLabels = []; + +const EVENT_TYPES = [ + "popupshown", + "popuphidden", + "PanelMultiViewHidden", + "ViewShowing", + "ViewShown", + "ViewHiding", +]; + +/** + * Checks that the element is displayed, including the state of the popup where + * the element is located. This can trigger a synchronous reflow if necessary, + * because even though the code under test is designed to avoid synchronous + * reflows, it can raise completion events while a layout flush is still needed. + * + * In production code, event handlers for ViewShown have to wait for a flush if + * they need to read style or layout information, like other code normally does. + */ +function is_visible(element) { + let win = element.ownerGlobal; + let style = win.getComputedStyle(element); + if (style.display == "none") { + return false; + } + if (style.visibility != "visible") { + return false; + } + if (win.XULPopupElement.isInstance(element) && element.state != "open") { + return false; + } + + // Hiding a parent element will hide all its children + if (element.parentNode != element.ownerDocument) { + return is_visible(element.parentNode); + } + + return true; +} + +/** + * Checks whether the label in the specified view is visible. + */ +function assertLabelVisible(viewIndex, expectedVisible) { + Assert.equal( + is_visible(gPanelViewLabels[viewIndex]), + expectedVisible, + `Visibility of label in view ${viewIndex}` + ); +} + +/** + * Opens the specified view as the main view in the specified panel. + */ +async function openPopup(panelIndex, viewIndex) { + gPanelMultiViews[panelIndex].setAttribute( + "mainViewId", + gPanelViews[viewIndex].id + ); + + let promiseShown = BrowserTestUtils.waitForEvent( + gPanelViews[viewIndex], + "ViewShown" + ); + PanelMultiView.openPopup( + gPanels[panelIndex], + gPanelAnchors[panelIndex], + "bottomright topright" + ); + await promiseShown; + + Assert.ok(PanelView.forNode(gPanelViews[viewIndex]).active); + assertLabelVisible(viewIndex, true); +} + +/** + * Closes the specified panel. + */ +async function hidePopup(panelIndex) { + gPanelMultiViews[panelIndex].setAttribute( + "mainViewId", + gPanelViews[panelIndex].id + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + gPanels[panelIndex], + "popuphidden" + ); + PanelMultiView.hidePopup(gPanels[panelIndex]); + await promiseHidden; +} + +/** + * Opens the specified subview in the specified panel. + */ +async function showSubView(panelIndex, viewIndex) { + let promiseShown = BrowserTestUtils.waitForEvent( + gPanelViews[viewIndex], + "ViewShown" + ); + gPanelMultiViews[panelIndex].showSubView(gPanelViews[viewIndex]); + await promiseShown; + + Assert.ok(PanelView.forNode(gPanelViews[viewIndex]).active); + assertLabelVisible(viewIndex, true); +} + +/** + * Navigates backwards to the specified view, which is displayed as a result. + */ +async function goBack(panelIndex, viewIndex) { + let promiseShown = BrowserTestUtils.waitForEvent( + gPanelViews[viewIndex], + "ViewShown" + ); + gPanelMultiViews[panelIndex].goBack(); + await promiseShown; + + Assert.ok(PanelView.forNode(gPanelViews[viewIndex]).active); + assertLabelVisible(viewIndex, true); +} + +/** + * Records the specified events on an element into the specified array. An + * optional callback can be used to respond to events and trigger nested events. + */ +function recordEvents( + element, + eventTypes, + recordArray, + eventCallback = () => {} +) { + let nestedEvents = []; + element.recorders = eventTypes.map(eventType => { + let recorder = { + eventType, + listener(event) { + let eventString = + nestedEvents.join("") + `${event.originalTarget.id}: ${event.type}`; + info(`Event on ${eventString}`); + recordArray.push(eventString); + // Any synchronous event triggered from within the given callback will + // include information about the current event. + nestedEvents.unshift(`${eventString} > `); + eventCallback(event); + nestedEvents.shift(); + }, + }; + element.addEventListener(recorder.eventType, recorder.listener); + return recorder; + }); +} + +/** + * Stops recording events on an element. + */ +function stopRecordingEvents(element) { + for (let recorder of element.recorders) { + element.removeEventListener(recorder.eventType, recorder.listener); + } + delete element.recorders; +} + +/** + * Sets up the elements in the browser window that will be used by all the other + * regression tests. Since the panel and view elements can live anywhere in the + * document, they are simply added to the same toolbar as the panel anchors. + * + * <toolbar id="nav-bar"> + * <toolbarbutton/> -> gPanelAnchors[panelIndex] + * <panel> -> gPanels[panelIndex] + * <panelmultiview/> -> gPanelMultiViews[panelIndex] + * </panel> + * <panelview> -> gPanelViews[viewIndex] + * <label/> -> gPanelViewLabels[viewIndex] + * </panelview> + * </toolbar> + */ +add_task(async function test_setup() { + let navBar = document.getElementById("nav-bar"); + + for (let i = 0; i < PANELS_COUNT; i++) { + gPanelAnchors[i] = document.createXULElement("toolbarbutton"); + gPanelAnchors[i].classList.add( + "toolbarbutton-1", + "chromeclass-toolbar-additional" + ); + navBar.appendChild(gPanelAnchors[i]); + + gPanels[i] = document.createXULElement("panel"); + gPanels[i].id = "panel-" + i; + gPanels[i].setAttribute("type", "arrow"); + gPanels[i].setAttribute("photon", true); + navBar.appendChild(gPanels[i]); + + gPanelMultiViews[i] = document.createXULElement("panelmultiview"); + gPanelMultiViews[i].id = "panelmultiview-" + i; + gPanels[i].appendChild(gPanelMultiViews[i]); + } + + for (let i = 0; i < PANELVIEWS_COUNT; i++) { + gPanelViews[i] = document.createXULElement("panelview"); + gPanelViews[i].id = "panelview-" + i; + navBar.appendChild(gPanelViews[i]); + + gPanelViewLabels[i] = document.createXULElement("label"); + gPanelViewLabels[i].setAttribute("value", "PanelView " + i); + gPanelViews[i].appendChild(gPanelViewLabels[i]); + } + + registerCleanupFunction(() => { + [...gPanelAnchors, ...gPanels, ...gPanelViews].forEach(e => e.remove()); + }); +}); + +/** + * Shows and hides all views in a panel with this static structure: + * + * - Panel 0 + * - View 0 + * - View 1 + * - View 3 + * - View 2 + */ +add_task(async function test_simple() { + // Show main view 0. + await openPopup(0, 0); + + // Show and hide subview 1. + await showSubView(0, 1); + assertLabelVisible(0, false); + await goBack(0, 0); + assertLabelVisible(1, false); + + // Show subview 3. + await showSubView(0, 3); + assertLabelVisible(0, false); + + // Show and hide subview 2. + await showSubView(0, 2); + assertLabelVisible(3, false); + await goBack(0, 3); + assertLabelVisible(2, false); + + // Hide subview 3. + await goBack(0, 0); + assertLabelVisible(3, false); + + // Hide main view 0. + await hidePopup(0); + assertLabelVisible(0, false); +}); + +/** + * Tests the event sequence in a panel with this static structure: + * + * - Panel 0 + * - View 0 + * - View 1 + * - View 3 + * - View 2 + */ +add_task(async function test_simple_event_sequence() { + let recordArray = []; + recordEvents(gPanels[0], EVENT_TYPES, recordArray); + + await openPopup(0, 0); + await showSubView(0, 1); + await goBack(0, 0); + await showSubView(0, 3); + await showSubView(0, 2); + await goBack(0, 3); + await goBack(0, 0); + await hidePopup(0); + + stopRecordingEvents(gPanels[0]); + + Assert.deepEqual(recordArray, [ + "panelview-0: ViewShowing", + "panelview-0: ViewShown", + "panel-0: popupshown", + "panelview-1: ViewShowing", + "panelview-1: ViewShown", + "panelview-1: ViewHiding", + "panelview-0: ViewShown", + "panelview-3: ViewShowing", + "panelview-3: ViewShown", + "panelview-2: ViewShowing", + "panelview-2: ViewShown", + "panelview-2: ViewHiding", + "panelview-3: ViewShown", + "panelview-3: ViewHiding", + "panelview-0: ViewShown", + "panelview-0: ViewHiding", + "panelmultiview-0: PanelMultiViewHidden", + "panel-0: popuphidden", + ]); +}); + +/** + * Tests that further navigation is suppressed until the new view is shown. + */ +add_task(async function test_navigation_suppression() { + await openPopup(0, 0); + + // Test re-entering the "showSubView" method. + let promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[1], "ViewShown"); + gPanelMultiViews[0].showSubView(gPanelViews[1]); + Assert.ok( + !PanelView.forNode(gPanelViews[0]).active, + "The previous view should become inactive synchronously." + ); + + // The following call will have no effect. + gPanelMultiViews[0].showSubView(gPanelViews[2]); + await promiseShown; + + // Test re-entering the "goBack" method. + promiseShown = BrowserTestUtils.waitForEvent(gPanelViews[0], "ViewShown"); + gPanelMultiViews[0].goBack(); + Assert.ok( + !PanelView.forNode(gPanelViews[1]).active, + "The previous view should become inactive synchronously." + ); + + // The following call will have no effect. + gPanelMultiViews[0].goBack(); + await promiseShown; + + // Main view 0 should be displayed. + assertLabelVisible(0, true); + + await hidePopup(0); +}); + +/** + * Tests reusing views that are already open in another panel. In this test, the + * structure of the first panel will change dynamically: + * + * - Panel 0 + * - View 0 + * - View 1 + * - Panel 1 + * - View 1 + * - View 2 + * - Panel 0 + * - View 1 + * - View 0 + */ +add_task(async function test_switch_event_sequence() { + let recordArray = []; + recordEvents(gPanels[0], EVENT_TYPES, recordArray); + recordEvents(gPanels[1], EVENT_TYPES, recordArray); + + // Show panel 0. + await openPopup(0, 0); + await showSubView(0, 1); + + // Show panel 1 with the view that is already open and visible in panel 0. + // This will close panel 0 automatically. + await openPopup(1, 1); + await showSubView(1, 2); + + // Show panel 0 with a view that is already open but invisible in panel 1. + // This will close panel 1 automatically. + await openPopup(0, 1); + await showSubView(0, 0); + + // Hide panel 0. + await hidePopup(0); + + stopRecordingEvents(gPanels[0]); + stopRecordingEvents(gPanels[1]); + + Assert.deepEqual(recordArray, [ + "panelview-0: ViewShowing", + "panelview-0: ViewShown", + "panel-0: popupshown", + "panelview-1: ViewShowing", + "panelview-1: ViewShown", + "panelview-1: ViewHiding", + "panelview-0: ViewHiding", + "panelmultiview-0: PanelMultiViewHidden", + "panel-0: popuphidden", + "panelview-1: ViewShowing", + "panel-1: popupshown", + "panelview-1: ViewShown", + "panelview-2: ViewShowing", + "panelview-2: ViewShown", + "panel-1: popuphidden", + "panelview-2: ViewHiding", + "panelview-1: ViewHiding", + "panelmultiview-1: PanelMultiViewHidden", + "panelview-1: ViewShowing", + "panelview-1: ViewShown", + "panel-0: popupshown", + "panelview-0: ViewShowing", + "panelview-0: ViewShown", + "panelview-0: ViewHiding", + "panelview-1: ViewHiding", + "panelmultiview-0: PanelMultiViewHidden", + "panel-0: popuphidden", + ]); +}); + +/** + * Tests the event sequence when opening the main view is canceled. + */ +add_task(async function test_cancel_mainview_event_sequence() { + let recordArray = []; + recordEvents(gPanels[0], EVENT_TYPES, recordArray, event => { + if (event.type == "ViewShowing") { + event.preventDefault(); + } + }); + + gPanelMultiViews[0].setAttribute("mainViewId", gPanelViews[0].id); + + let promiseHidden = BrowserTestUtils.waitForEvent(gPanels[0], "popuphidden"); + PanelMultiView.openPopup( + gPanels[0], + gPanelAnchors[0], + "bottomright topright" + ); + await promiseHidden; + + stopRecordingEvents(gPanels[0]); + + Assert.deepEqual(recordArray, [ + "panelview-0: ViewShowing", + "panelview-0: ViewHiding", + "panelmultiview-0: PanelMultiViewHidden", + "panelmultiview-0: popuphidden", + ]); +}); + +/** + * Tests the event sequence when opening a subview is canceled. + */ +add_task(async function test_cancel_subview_event_sequence() { + let recordArray = []; + recordEvents(gPanels[0], EVENT_TYPES, recordArray, event => { + if ( + event.type == "ViewShowing" && + event.originalTarget.id == gPanelViews[1].id + ) { + event.preventDefault(); + } + }); + + await openPopup(0, 0); + + let promiseHiding = BrowserTestUtils.waitForEvent( + gPanelViews[1], + "ViewHiding" + ); + gPanelMultiViews[0].showSubView(gPanelViews[1]); + await promiseHiding; + + // Only the subview should have received the hidden event at this point. + Assert.deepEqual(recordArray, [ + "panelview-0: ViewShowing", + "panelview-0: ViewShown", + "panel-0: popupshown", + "panelview-1: ViewShowing", + "panelview-1: ViewHiding", + ]); + recordArray.length = 0; + + await hidePopup(0); + + stopRecordingEvents(gPanels[0]); + + Assert.deepEqual(recordArray, [ + "panelview-0: ViewHiding", + "panelmultiview-0: PanelMultiViewHidden", + "panel-0: popuphidden", + ]); +}); + +/** + * Tests the event sequence when closing the panel while opening the main view. + */ +add_task(async function test_close_while_showing_mainview_event_sequence() { + let recordArray = []; + recordEvents(gPanels[0], EVENT_TYPES, recordArray, event => { + if (event.type == "ViewShowing") { + PanelMultiView.hidePopup(gPanels[0]); + } + }); + + gPanelMultiViews[0].setAttribute("mainViewId", gPanelViews[0].id); + + let promiseHidden = BrowserTestUtils.waitForEvent(gPanels[0], "popuphidden"); + let promiseHiding = BrowserTestUtils.waitForEvent( + gPanelViews[0], + "ViewHiding" + ); + PanelMultiView.openPopup( + gPanels[0], + gPanelAnchors[0], + "bottomright topright" + ); + await promiseHiding; + await promiseHidden; + + stopRecordingEvents(gPanels[0]); + + Assert.deepEqual(recordArray, [ + "panelview-0: ViewShowing", + "panelview-0: ViewShowing > panelview-0: ViewHiding", + "panelview-0: ViewShowing > panelmultiview-0: PanelMultiViewHidden", + "panelview-0: ViewShowing > panelmultiview-0: popuphidden", + ]); +}); + +/** + * Tests the event sequence when closing the panel while opening a subview. + */ +add_task(async function test_close_while_showing_subview_event_sequence() { + let recordArray = []; + recordEvents(gPanels[0], EVENT_TYPES, recordArray, event => { + if ( + event.type == "ViewShowing" && + event.originalTarget.id == gPanelViews[1].id + ) { + PanelMultiView.hidePopup(gPanels[0]); + } + }); + + await openPopup(0, 0); + + let promiseHidden = BrowserTestUtils.waitForEvent(gPanels[0], "popuphidden"); + gPanelMultiViews[0].showSubView(gPanelViews[1]); + await promiseHidden; + + stopRecordingEvents(gPanels[0]); + + Assert.deepEqual(recordArray, [ + "panelview-0: ViewShowing", + "panelview-0: ViewShown", + "panel-0: popupshown", + "panelview-1: ViewShowing", + "panelview-1: ViewShowing > panelview-1: ViewHiding", + "panelview-1: ViewShowing > panelview-0: ViewHiding", + "panelview-1: ViewShowing > panelmultiview-0: PanelMultiViewHidden", + "panelview-1: ViewShowing > panel-0: popuphidden", + ]); +}); diff --git a/browser/components/customizableui/test/browser_PanelMultiView_focus.js b/browser/components/customizableui/test/browser_PanelMultiView_focus.js new file mode 100644 index 0000000000..bbc0bbc1c7 --- /dev/null +++ b/browser/components/customizableui/test/browser_PanelMultiView_focus.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test the focus behavior when opening PanelViews. + */ + +let gAnchor; +let gPanel; +let gPanelMultiView; +let gMainView; +let gMainButton; +let gMainSubButton; +let gSubView; +let gSubButton; + +function createWith(doc, tag, props) { + let el = doc.createXULElement(tag); + for (let prop in props) { + el.setAttribute(prop, props[prop]); + } + return el; +} + +add_setup(async function () { + let navBar = document.getElementById("nav-bar"); + gAnchor = document.createXULElement("toolbarbutton"); + // Must be focusable in order for key presses to work. + gAnchor.style["-moz-user-focus"] = "normal"; + navBar.appendChild(gAnchor); + let onPress = event => + PanelMultiView.openPopup(gPanel, gAnchor, { + triggerEvent: event, + }); + gAnchor.addEventListener("keypress", onPress); + gAnchor.addEventListener("click", onPress); + gAnchor.setAttribute("aria-label", "test label"); + gPanel = document.createXULElement("panel"); + navBar.appendChild(gPanel); + gPanelMultiView = document.createXULElement("panelmultiview"); + gPanelMultiView.setAttribute("mainViewId", "testMainView"); + gPanel.appendChild(gPanelMultiView); + + gMainView = document.createXULElement("panelview"); + gMainView.id = "testMainView"; + gPanelMultiView.appendChild(gMainView); + gMainButton = createWith(document, "button", { label: "gMainButton" }); + gMainView.appendChild(gMainButton); + gMainSubButton = createWith(document, "button", { label: "gMainSubButton" }); + gMainView.appendChild(gMainSubButton); + gMainSubButton.addEventListener("command", () => + gPanelMultiView.showSubView("testSubView", gMainSubButton) + ); + + gSubView = document.createXULElement("panelview"); + gSubView.id = "testSubView"; + gPanelMultiView.appendChild(gSubView); + gSubButton = createWith(document, "button", { label: "gSubButton" }); + gSubView.appendChild(gSubButton); + + registerCleanupFunction(() => { + gAnchor.remove(); + gPanel.remove(); + }); +}); + +// Activate the main view by pressing a key. Focus should be moved inside. +add_task(async function testMainViewByKeypress() { + gAnchor.focus(); + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, () => + EventUtils.synthesizeKey(" ") + ); + Assert.equal( + document.activeElement, + gMainButton, + "Focus on button in main view" + ); + await gCUITestUtils.hidePanelMultiView(gPanel, () => + PanelMultiView.hidePopup(gPanel) + ); +}); + +// Activate the main view by clicking the mouse. Focus should not be moved +// inside. +add_task(async function testMainViewByClick() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, () => + gAnchor.click() + ); + Assert.notEqual( + document.activeElement, + gMainButton, + "Focus not on button in main view" + ); + await gCUITestUtils.hidePanelMultiView(gPanel, () => + PanelMultiView.hidePopup(gPanel) + ); +}); + +// Activate the subview by pressing a key. Focus should be moved to the first +// button after the Back button. +add_task(async function testSubViewByKeypress() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, () => + gAnchor.click() + ); + while (document.activeElement != gMainSubButton) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + } + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + EventUtils.synthesizeKey(" "); + await shown; + Assert.equal( + document.activeElement, + gSubButton, + "Focus on first button after Back button in subview" + ); + await gCUITestUtils.hidePanelMultiView(gPanel, () => + PanelMultiView.hidePopup(gPanel) + ); +}); + +// Activate the subview by clicking the mouse. Focus should not be moved +// inside. +add_task(async function testSubViewByClick() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, () => + gAnchor.click() + ); + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + gMainSubButton.click(); + await shown; + let backButton = gSubView.querySelector(".subviewbutton-back"); + Assert.notEqual( + document.activeElement, + backButton, + "Focus not on Back button in subview" + ); + Assert.notEqual( + document.activeElement, + gSubButton, + "Focus not on button after Back button in subview" + ); + await gCUITestUtils.hidePanelMultiView(gPanel, () => + PanelMultiView.hidePopup(gPanel) + ); +}); + +// Test that focus is restored when going back to a previous view. +add_task(async function testBackRestoresFocus() { + await gCUITestUtils.openPanelMultiView(gPanel, gMainView, () => + gAnchor.click() + ); + while (document.activeElement != gMainSubButton) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + } + let shown = BrowserTestUtils.waitForEvent(gSubView, "ViewShown"); + EventUtils.synthesizeKey(" "); + await shown; + shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await shown; + Assert.equal( + document.activeElement, + gMainSubButton, + "Focus on sub button in main view" + ); + await gCUITestUtils.hidePanelMultiView(gPanel, () => + PanelMultiView.hidePopup(gPanel) + ); +}); diff --git a/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js b/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js new file mode 100644 index 0000000000..b41fc2ef23 --- /dev/null +++ b/browser/components/customizableui/test/browser_PanelMultiView_keyboard.js @@ -0,0 +1,583 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test the keyboard behavior of PanelViews. + */ + +const kEmbeddedDocUrl = + 'data:text/html,<textarea id="docTextarea">value</textarea><button id="docButton"></button>'; + +let gAnchor; +let gPanel; +let gPanelMultiView; +let gMainView; +let gMainContext; +let gMainButton1; +let gMainMenulist; +let gMainRadiogroup; +let gMainTextbox; +let gMainButton2; +let gMainButton3; +let gCheckbox; +let gNamespacedLink; +let gLink; +let gMainTabOrder; +let gMainArrowOrder; +let gSubView; +let gSubButton; +let gSubTextarea; +let gBrowserView; +let gBrowserBrowser; +let gIframeView; +let gIframeIframe; +let gToggle; + +async function openPopup() { + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + PanelMultiView.openPopup(gPanel, gAnchor, "bottomright topright"); + await shown; +} + +async function hidePopup() { + let hidden = BrowserTestUtils.waitForEvent(gPanel, "popuphidden"); + PanelMultiView.hidePopup(gPanel); + await hidden; +} + +async function showSubView(view = gSubView) { + let shown = BrowserTestUtils.waitForEvent(view, "ViewShown"); + // We must show with an anchor so the Back button is generated. + gPanelMultiView.showSubView(view, gMainButton1); + await shown; +} + +async function expectFocusAfterKey(aKey, aFocus) { + let res = aKey.match(/^(Shift\+)?(.+)$/); + let shift = Boolean(res[1]); + let key; + if (res[2].length == 1) { + key = res[2]; // Character. + } else { + key = "KEY_" + res[2]; // Tab, ArrowRight, etc. + } + info("Waiting for focus on " + aFocus.id); + let focused = BrowserTestUtils.waitForEvent(aFocus, "focus"); + EventUtils.synthesizeKey(key, { shiftKey: shift }); + await focused; + ok(true, aFocus.id + " focused after " + aKey + " pressed"); +} + +add_setup(async function () { + // This shouldn't be necessary - but it is, because we use same-process frames. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1565276 covers improving this. + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + let navBar = document.getElementById("nav-bar"); + gAnchor = document.createXULElement("toolbarbutton"); + navBar.appendChild(gAnchor); + gPanel = document.createXULElement("panel"); + navBar.appendChild(gPanel); + gPanelMultiView = document.createXULElement("panelmultiview"); + gPanelMultiView.setAttribute("mainViewId", "testMainView"); + gPanel.appendChild(gPanelMultiView); + + gMainView = document.createXULElement("panelview"); + gMainView.id = "testMainView"; + gPanelMultiView.appendChild(gMainView); + gMainContext = document.createXULElement("menupopup"); + gMainContext.id = "gMainContext"; + gMainView.appendChild(gMainContext); + gMainContext.appendChild(document.createXULElement("menuitem")); + gMainButton1 = document.createXULElement("button"); + gMainButton1.id = "gMainButton1"; + gMainView.appendChild(gMainButton1); + // We use this for anchoring subviews, so it must have a label. + gMainButton1.setAttribute("label", "gMainButton1"); + gMainButton1.setAttribute("context", "gMainContext"); + gMainMenulist = document.createXULElement("menulist"); + gMainMenulist.id = "gMainMenulist"; + gMainView.appendChild(gMainMenulist); + let menuPopup = document.createXULElement("menupopup"); + gMainMenulist.appendChild(menuPopup); + let item = document.createXULElement("menuitem"); + item.setAttribute("value", "1"); + item.setAttribute("selected", "true"); + menuPopup.appendChild(item); + item = document.createXULElement("menuitem"); + item.setAttribute("value", "2"); + menuPopup.appendChild(item); + gMainRadiogroup = document.createXULElement("radiogroup"); + gMainRadiogroup.id = "gMainRadiogroup"; + gMainView.appendChild(gMainRadiogroup); + let radio = document.createXULElement("radio"); + radio.setAttribute("value", "1"); + radio.setAttribute("selected", "true"); + gMainRadiogroup.appendChild(radio); + radio = document.createXULElement("radio"); + radio.setAttribute("value", "2"); + gMainRadiogroup.appendChild(radio); + gMainTextbox = document.createElement("input"); + gMainTextbox.id = "gMainTextbox"; + gMainView.appendChild(gMainTextbox); + gMainTextbox.setAttribute("value", "value"); + gMainButton2 = document.createXULElement("button"); + gMainButton2.id = "gMainButton2"; + gMainView.appendChild(gMainButton2); + gMainButton3 = document.createXULElement("button"); + gMainButton3.id = "gMainButton3"; + gMainView.appendChild(gMainButton3); + gCheckbox = document.createXULElement("checkbox"); + gCheckbox.id = "gCheckbox"; + gMainView.appendChild(gCheckbox); + + // moz-support-links in XUL documents are created with the + // <html:a> tag and so we need to test this separately from + // <a> tags. + gNamespacedLink = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:a" + ); + gNamespacedLink.href = "www.mozilla.org"; + gNamespacedLink.innerText = "gNamespacedLink"; + gNamespacedLink.id = "gNamespacedLink"; + gMainView.appendChild(gNamespacedLink); + gLink = document.createElement("a"); + gLink.href = "www.mozilla.org"; + gLink.innerText = "gLink"; + gLink.id = "gLink"; + gMainView.appendChild(gLink); + await window.ensureCustomElements("moz-toggle"); + gToggle = document.createElement("moz-toggle"); + gToggle.label = "Test label"; + gMainView.appendChild(gToggle); + + gMainTabOrder = [ + gMainButton1, + gMainMenulist, + gMainRadiogroup, + gMainTextbox, + gMainButton2, + gMainButton3, + gCheckbox, + gNamespacedLink, + gLink, + gToggle, + ]; + gMainArrowOrder = [ + gMainButton1, + gMainButton2, + gMainButton3, + gCheckbox, + gNamespacedLink, + gLink, + gToggle, + ]; + + gSubView = document.createXULElement("panelview"); + gSubView.id = "testSubView"; + gPanelMultiView.appendChild(gSubView); + gSubButton = document.createXULElement("button"); + gSubView.appendChild(gSubButton); + gSubTextarea = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "textarea" + ); + gSubTextarea.id = "gSubTextarea"; + gSubView.appendChild(gSubTextarea); + gSubTextarea.value = "value"; + + gBrowserView = document.createXULElement("panelview"); + gBrowserView.id = "testBrowserView"; + gPanelMultiView.appendChild(gBrowserView); + gBrowserBrowser = document.createXULElement("browser"); + gBrowserBrowser.id = "GBrowserBrowser"; + gBrowserBrowser.setAttribute("type", "content"); + gBrowserBrowser.setAttribute("src", kEmbeddedDocUrl); + gBrowserBrowser.style.minWidth = gBrowserBrowser.style.minHeight = "100px"; + gBrowserView.appendChild(gBrowserBrowser); + + gIframeView = document.createXULElement("panelview"); + gIframeView.id = "testIframeView"; + gPanelMultiView.appendChild(gIframeView); + gIframeIframe = document.createXULElement("iframe"); + gIframeIframe.id = "gIframeIframe"; + gIframeIframe.setAttribute("src", kEmbeddedDocUrl); + gIframeView.appendChild(gIframeIframe); + + registerCleanupFunction(() => { + gAnchor.remove(); + gPanel.remove(); + }); +}); + +// Test that the tab key focuses all expected controls. +add_task(async function testTab() { + await openPopup(); + for (let elem of gMainTabOrder) { + await expectFocusAfterKey("Tab", elem); + } + // Wrap around. + await expectFocusAfterKey("Tab", gMainTabOrder[0]); + await hidePopup(); +}); + +// Test that the shift+tab key focuses all expected controls. +add_task(async function testShiftTab() { + await openPopup(); + for (let i = gMainTabOrder.length - 1; i >= 0; --i) { + await expectFocusAfterKey("Shift+Tab", gMainTabOrder[i]); + } + // Wrap around. + await expectFocusAfterKey( + "Shift+Tab", + gMainTabOrder[gMainTabOrder.length - 1] + ); + await hidePopup(); +}); + +// Test that the down arrow key skips menulists and textboxes. +add_task(async function testDownArrow() { + await openPopup(); + for (let elem of gMainArrowOrder) { + await expectFocusAfterKey("ArrowDown", elem); + } + // Wrap around. + await expectFocusAfterKey("ArrowDown", gMainArrowOrder[0]); + await hidePopup(); +}); + +// Test that the up arrow key skips menulists and textboxes. +add_task(async function testUpArrow() { + await openPopup(); + for (let i = gMainArrowOrder.length - 1; i >= 0; --i) { + await expectFocusAfterKey("ArrowUp", gMainArrowOrder[i]); + } + // Wrap around. + await expectFocusAfterKey( + "ArrowUp", + gMainArrowOrder[gMainArrowOrder.length - 1] + ); + await hidePopup(); +}); + +// Test that the home/end keys move to the first/last controls. +add_task(async function testHomeEnd() { + await openPopup(); + await expectFocusAfterKey("Home", gMainArrowOrder[0]); + await expectFocusAfterKey("End", gMainArrowOrder[gMainArrowOrder.length - 1]); + await hidePopup(); +}); + +// Test that the up/down arrow keys work as expected in menulists. +add_task(async function testArrowsMenulist() { + await openPopup(); + gMainMenulist.focus(); + is(document.activeElement, gMainMenulist, "menulist focused"); + is(gMainMenulist.value, "1", "menulist initial value 1"); + if (AppConstants.platform == "macosx") { + // On Mac, down/up arrows just open the menulist. + let popup = gMainMenulist.menupopup; + for (let key of ["ArrowDown", "ArrowUp"]) { + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + EventUtils.synthesizeKey("KEY_" + key); + await shown; + ok(gMainMenulist.open, "menulist open after " + key); + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await hidden; + ok(!gMainMenulist.open, "menulist closed after Escape"); + } + } else { + // On other platforms, down/up arrows change the value without opening the + // menulist. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + document.activeElement, + gMainMenulist, + "menulist still focused after ArrowDown" + ); + is(gMainMenulist.value, "2", "menulist value 2 after ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + document.activeElement, + gMainMenulist, + "menulist still focused after ArrowUp" + ); + is(gMainMenulist.value, "1", "menulist value 1 after ArrowUp"); + } + await hidePopup(); +}); + +// Test that the tab key closes an open menu list. +add_task(async function testTabOpenMenulist() { + await openPopup(); + gMainMenulist.focus(); + is(document.activeElement, gMainMenulist, "menulist focused"); + let popup = gMainMenulist.menupopup; + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + gMainMenulist.open = true; + await shown; + ok(gMainMenulist.open, "menulist open"); + let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await menuHidden; + ok(!gMainMenulist.open, "menulist closed after Tab"); + is(gPanel.state, "open", "Panel should be open"); + await hidePopup(); +}); + +if (AppConstants.platform == "macosx") { + // Test that using the mouse to open a menulist still allows keyboard navigation + // inside it. + add_task(async function testNavigateMouseOpenedMenulist() { + await openPopup(); + let popup = gMainMenulist.menupopup; + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + gMainMenulist.open = true; + await shown; + ok(gMainMenulist.open, "menulist open"); + let oldFocus = document.activeElement; + let oldSelectedItem = gMainMenulist.selectedItem; + ok( + oldSelectedItem.hasAttribute("_moz-menuactive"), + "Selected item should show up as active" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition( + () => !oldSelectedItem.hasAttribute("_moz-menuactive") + ); + is(oldFocus, document.activeElement, "Focus should not move on mac"); + ok( + !oldSelectedItem.hasAttribute("_moz-menuactive"), + "Selected item should change" + ); + + let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await menuHidden; + ok(!gMainMenulist.open, "menulist closed after Tab"); + is(gPanel.state, "open", "Panel should be open"); + await hidePopup(); + }); +} + +// Test that the up/down arrow keys work as expected in radiogroups. +add_task(async function testArrowsRadiogroup() { + await openPopup(); + gMainRadiogroup.focus(); + is(document.activeElement, gMainRadiogroup, "radiogroup focused"); + is(gMainRadiogroup.value, "1", "radiogroup initial value 1"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + document.activeElement, + gMainRadiogroup, + "radiogroup still focused after ArrowDown" + ); + is(gMainRadiogroup.value, "2", "radiogroup value 2 after ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + document.activeElement, + gMainRadiogroup, + "radiogroup still focused after ArrowUp" + ); + is(gMainRadiogroup.value, "1", "radiogroup value 1 after ArrowUp"); + await hidePopup(); +}); + +// Test that pressing space in a textbox inserts a space (instead of trying to +// activate the control). +add_task(async function testSpaceTextbox() { + await openPopup(); + gMainTextbox.focus(); + gMainTextbox.selectionStart = gMainTextbox.selectionEnd = 0; + EventUtils.synthesizeKey(" "); + is(gMainTextbox.value, " value", "Space typed into textbox"); + gMainTextbox.value = "value"; + await hidePopup(); +}); + +// Tests that the left arrow key normally moves back to the previous view. +add_task(async function testLeftArrow() { + await openPopup(); + await showSubView(); + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await shown; + ok("Moved to previous view after ArrowLeft"); + await hidePopup(); +}); + +// Tests that the left arrow key moves the caret in a textarea in a subview +// (instead of going back to the previous view). +add_task(async function testLeftArrowTextarea() { + await openPopup(); + await showSubView(); + gSubTextarea.focus(); + is(document.activeElement, gSubTextarea, "textarea focused"); + EventUtils.synthesizeKey("KEY_End"); + is(gSubTextarea.selectionStart, 5, "selectionStart 5 after End"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is(gSubTextarea.selectionStart, 4, "selectionStart 4 after ArrowLeft"); + is(document.activeElement, gSubTextarea, "textarea still focused"); + await hidePopup(); +}); + +// Test navigation to a button which is initially disabled and later enabled. +add_task(async function testDynamicButton() { + gMainButton2.disabled = true; + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + await expectFocusAfterKey("ArrowDown", gMainButton3); + gMainButton2.disabled = false; + await expectFocusAfterKey("ArrowUp", gMainButton2); + await hidePopup(); +}); + +add_task(async function testActivation() { + function checkActivated(elem, activationFn, reason) { + let activated = false; + elem.onclick = function () { + activated = true; + }; + activationFn(); + ok(activated, "Should have activated button after " + reason); + elem.onclick = null; + } + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey("KEY_Enter"), + "pressing enter" + ); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey(" "), + "pressing space" + ); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey("KEY_Enter", { code: "NumpadEnter" }), + "pressing numpad enter" + ); + await hidePopup(); +}); + +// Test that keyboard activation works for buttons responding to mousedown +// events (instead of command or click). The Library button does this, for +// example. +add_task(async function testActivationMousedown() { + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + let activated = false; + gMainButton1.onmousedown = function () { + activated = true; + }; + EventUtils.synthesizeKey(" "); + ok(activated, "mousedown activated after space"); + gMainButton1.onmousedown = null; + await hidePopup(); +}); + +// Test that tab and the arrow keys aren't overridden in embedded documents. +async function testTabArrowsEmbeddedDoc(aView, aEmbedder) { + await openPopup(); + await showSubView(aView); + let doc = aEmbedder.contentDocument; + if (doc.readyState != "complete" || doc.location.href != kEmbeddedDocUrl) { + info(`Embedded doc readyState ${doc.readyState}, location ${doc.location}`); + info("Waiting for load on embedder"); + // Browsers don't fire load events, and iframes don't fire load events in + // typeChrome windows. We can handle both by using a capturing event + // listener to capture the load event from the child document. + await BrowserTestUtils.waitForEvent(aEmbedder, "load", true); + // The original doc might have been a temporary about:blank, so fetch it + // again. + doc = aEmbedder.contentDocument; + } + is(doc.location.href, kEmbeddedDocUrl, "Embedded doc has correct URl"); + let backButton = aView.querySelector(".subviewbutton-back"); + backButton.id = "docBack"; + await expectFocusAfterKey("Tab", backButton); + // Documents don't have an id property, but expectFocusAfterKey wants one. + doc.id = "doc"; + await expectFocusAfterKey("Tab", doc); + // Make sure tab/arrows aren't overridden within the embedded document. + let textarea = doc.getElementById("docTextarea"); + // Tab should really focus the textarea, but default tab handling seems to + // skip everything inside the embedder element when run in this test. This + // behaves as expected in real panels, though. Force focus to the textarea + // and then test from there. + textarea.focus(); + is(doc.activeElement, textarea, "textarea focused"); + is(textarea.selectionStart, 0, "selectionStart initially 0"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + is(textarea.selectionStart, 1, "selectionStart 1 after ArrowRight"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is(textarea.selectionStart, 0, "selectionStart 0 after ArrowLeft"); + is(doc.activeElement, textarea, "textarea still focused"); + let docButton = doc.getElementById("docButton"); + await expectFocusAfterKey("Tab", docButton); + await hidePopup(); +} + +// Test that tab and the arrow keys aren't overridden in embedded browsers. +add_task(async function testTabArrowsBrowser() { + await testTabArrowsEmbeddedDoc(gBrowserView, gBrowserBrowser); +}); + +// Test that tab and the arrow keys aren't overridden in embedded iframes. +add_task(async function testTabArrowsIframe() { + await testTabArrowsEmbeddedDoc(gIframeView, gIframeIframe); +}); + +// Test that the arrow keys aren't overridden in context menus. +add_task(async function testArowsContext() { + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + let shown = BrowserTestUtils.waitForEvent(gMainContext, "popupshown"); + // There's no cross-platform way to open a context menu from the keyboard. + gMainContext.openPopup(gMainButton1); + await shown; + let item = gMainContext.children[0]; + ok( + !item.getAttribute("_moz-menuactive"), + "First context menu item initially inactive" + ); + let active = BrowserTestUtils.waitForEvent(item, "DOMMenuItemActive"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await active; + ok( + item.getAttribute("_moz-menuactive"), + "First context menu item active after ArrowDown" + ); + is( + document.activeElement, + gMainButton1, + "gMainButton1 still focused after ArrowDown" + ); + let hidden = BrowserTestUtils.waitForEvent(gMainContext, "popuphidden"); + gMainContext.hidePopup(); + await hidden; + await hidePopup(); +}); + +add_task(async function testMozToggle() { + await openPopup(); + is(gToggle.pressed, false, "The toggle is not pressed initially."); + // Focus the toggle via keyboard navigation. + while (document.activeElement !== gToggle) { + EventUtils.synthesizeKey("KEY_Tab"); + } + EventUtils.synthesizeKey(" "); + await gToggle.updateComplete; + is(gToggle.pressed, true, "Toggle pressed state changes via spacebar."); + EventUtils.synthesizeKey("KEY_Enter"); + await gToggle.updateComplete; + is(gToggle.pressed, false, "Toggle pressed state changes via enter."); + await hidePopup(); +}); diff --git a/browser/components/customizableui/test/browser_addons_area.js b/browser/components/customizableui/test/browser_addons_area.js new file mode 100644 index 0000000000..533d48b238 --- /dev/null +++ b/browser/components/customizableui/test/browser_addons_area.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that widgets provided by extensions can be added to the + * ADDONS area, but all other widgets cannot. + */ +add_task(async function test_only_extension_widgets_in_addons_area() { + registerCleanupFunction(async () => { + await CustomizableUI.reset(); + }); + + Assert.ok( + !CustomizableUI.canWidgetMoveToArea( + "home-button", + CustomizableUI.AREA_ADDONS + ), + "Cannot move a built-in button to the ADDONS area." + ); + + // Now double-check that we cannot accidentally default a non-extension + // widget into the ADDONS area. + const kTestDynamicWidget = "a-test-widget"; + CustomizableUI.createWidget({ + id: kTestDynamicWidget, + label: "Test widget", + defaultArea: CustomizableUI.AREA_ADDONS, + }); + Assert.equal( + CustomizableUI.getPlacementOfWidget(kTestDynamicWidget), + null, + "An attempt to put a non-extension widget into the ADDONS area by default should fail." + ); + CustomizableUI.destroyWidget(kTestDynamicWidget); + + const kWebExtensionButtonID1 = "a-test-extension-button"; + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID1, + label: "Test extension widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + Assert.ok( + CustomizableUI.canWidgetMoveToArea( + kWebExtensionButtonID1, + CustomizableUI.AREA_ADDONS + ), + "Can move extension button to the addons area." + ); + + CustomizableUI.destroyWidget(kWebExtensionButtonID1); + + // Now check that extension buttons can default to the ADDONS area, if need + // be. + + const kWebExtensionButtonID2 = "a-test-extension-button-2"; + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID2, + label: "Test extension widget 2", + defaultArea: CustomizableUI.AREA_ADDONS, + webExtension: true, + }); + + Assert.equal( + CustomizableUI.getPlacementOfWidget(kWebExtensionButtonID2)?.area, + CustomizableUI.AREA_ADDONS, + "An attempt to put an extension widget into the ADDONS area by default should work." + ); + + CustomizableUI.destroyWidget(kWebExtensionButtonID2); +}); diff --git a/browser/components/customizableui/test/browser_allow_dragging_removable_false.js b/browser/components/customizableui/test/browser_allow_dragging_removable_false.js new file mode 100644 index 0000000000..76269f44ae --- /dev/null +++ b/browser/components/customizableui/test/browser_allow_dragging_removable_false.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * Test dragging a removable=false widget within its own area as well as to the palette. + */ +add_task(async function () { + await startCustomizing(); + let forwardButton = document.getElementById("forward-button"); + is( + forwardButton.getAttribute("removable"), + "false", + "forward-button should not be removable" + ); + ok(CustomizableUI.inDefaultState, "Should start in default state."); + + let urlbarContainer = document.getElementById("urlbar-container"); + let placementsAfterDrag = getAreaWidgetIds(CustomizableUI.AREA_NAVBAR); + placementsAfterDrag.splice(placementsAfterDrag.indexOf("forward-button"), 1); + placementsAfterDrag.splice( + placementsAfterDrag.indexOf("urlbar-container"), + 0, + "forward-button" + ); + + // Force layout flush to ensure the drag completes as expected + urlbarContainer.clientWidth; + + simulateItemDrag(forwardButton, urlbarContainer, "start"); + assertAreaPlacements(CustomizableUI.AREA_NAVBAR, placementsAfterDrag); + ok(!CustomizableUI.inDefaultState, "Should no longer be in default state."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(forwardButton, palette); + is( + CustomizableUI.getPlacementOfWidget("forward-button").area, + CustomizableUI.AREA_NAVBAR, + "forward-button was not able to move to palette" + ); + + await endCustomizing(); + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Should be in default state again."); +}); diff --git a/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js b/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js new file mode 100644 index 0000000000..93d88c7c55 --- /dev/null +++ b/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +/** + * Back/fwd buttons should be re-enabled after customizing. + */ +add_task(async function test_back_forward_buttons() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH); + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,A separate page" + ); + await loaded; + loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,Another separate page" + ); + await loaded; + gBrowser.goBack(); + await BrowserTestUtils.waitForCondition(() => gBrowser.canGoForward); + + let backButton = document.getElementById("back-button"); + let forwardButton = document.getElementById("forward-button"); + + await BrowserTestUtils.waitForCondition( + () => + !backButton.hasAttribute("disabled") && + !forwardButton.hasAttribute("disabled") + ); + + ok(!backButton.hasAttribute("disabled"), "Back button shouldn't be disabled"); + ok( + !forwardButton.hasAttribute("disabled"), + "Forward button shouldn't be disabled" + ); + await startCustomizing(); + + is( + backButton.getAttribute("disabled"), + "true", + "Back button should be disabled in customize mode" + ); + is( + forwardButton.getAttribute("disabled"), + "true", + "Forward button should be disabled in customize mode" + ); + + await endCustomizing(); + + await BrowserTestUtils.waitForCondition( + () => + !backButton.hasAttribute("disabled") && + !forwardButton.hasAttribute("disabled") + ); + + ok( + !backButton.hasAttribute("disabled"), + "Back button shouldn't be disabled after customize mode" + ); + ok( + !forwardButton.hasAttribute("disabled"), + "Forward button shouldn't be disabled after customize mode" + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_empty_message.js b/browser/components/customizableui/test/browser_bookmarks_empty_message.js new file mode 100644 index 0000000000..f4497cefdb --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_empty_message.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function emptyToolbarMessageVisible(visible, win = window) { + info("Empty toolbar message should be " + (visible ? "visible" : "hidden")); + let emptyMessage = win.document.getElementById("personal-toolbar-empty"); + await BrowserTestUtils.waitForMutationCondition( + emptyMessage, + { attributes: true, attributeFilter: ["hidden"] }, + () => emptyMessage.hidden != visible + ); +} + +add_task(async function empty_message_on_non_empty_bookmarks_toolbar() { + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + + CustomizableUI.removeWidgetFromArea("import-button"); + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_BOOKMARKS, + 0 + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let doc = newWin.document; + ok( + BrowserTestUtils.isVisible(doc.getElementById("PersonalToolbar")), + "Personal toolbar should be visible" + ); + ok( + doc.getElementById("personal-toolbar-empty").hidden, + "Empty message should be hidden" + ); + + await BrowserTestUtils.closeWindow(newWin); + await resetCustomization(); +}); + +add_task(async function empty_message_after_customization() { + // ensure There's something on the toolbar. + let bm = await PlacesUtils.bookmarks.insert({ + url: "https://mozilla.org/", + title: "test", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + registerCleanupFunction(() => PlacesUtils.bookmarks.remove(bm)); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + // Open window with a visible toolbar. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let doc = newWin.document; + let toolbar = doc.getElementById("PersonalToolbar"); + ok(BrowserTestUtils.isVisible(toolbar), "Personal toolbar should be visible"); + await emptyToolbarMessageVisible(false, newWin); + + // Force a Places view uninit through customization. + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + await resetCustomization(); + // Show the toolbar again. + setToolbarVisibility(toolbar, true, false, false); + ok(BrowserTestUtils.isVisible(toolbar), "Personal toolbar should be visible"); + // Wait for bookmarks to be visible. + let placesItems = doc.getElementById("PlacesToolbarItems"); + await BrowserTestUtils.waitForMutationCondition( + placesItems, + { childList: true }, + () => placesItems.childNodes.length + ); + await emptyToolbarMessageVisible(false, newWin); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js b/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js new file mode 100644 index 0000000000..84ddc37d29 --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +// Restoring default should set Bookmarks Toolbar back to "newtab" +add_task(async function () { + let prefName = "browser.toolbars.bookmarks.visibility"; + let toolbar = document.querySelector("#PersonalToolbar"); + for (let state of ["always", "never"]) { + info(`Testing setting toolbar state to '${state}'`); + + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + setToolbarVisibility(toolbar, state, true, false); + + is( + Services.prefs.getCharPref(prefName), + state, + "Pref updated to: " + state + ); + ok(!CustomizableUI.inDefaultState, "Not in default state"); + + await resetCustomization(); + + ok(CustomizableUI.inDefaultState, "Back in default state after reset"); + is( + Services.prefs.getCharPref(prefName), + "newtab", + "Pref should get reset to 'newtab'" + ); + } +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js b/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js new file mode 100644 index 0000000000..38f385e38c --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +// Entering customize mode should show the toolbar as long as it's not set to "never" +add_task(async function () { + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + let toolbar = document.querySelector("#PersonalToolbar"); + for (let state of ["always", "never", "newtab"]) { + info(`Testing setting toolbar state to '${state}'`); + + setToolbarVisibility(toolbar, state, true, false); + + await startCustomizing(); + + let expected = state != "never"; + await TestUtils.waitForCondition( + () => !toolbar.collapsed == expected, + `Waiting for toolbar visibility, state=${state}, visible=${!toolbar.collapsed}, expected=${expected}` + ); + is( + !toolbar.collapsed, + expected, + "The toolbar should be visible when state isn't 'never'" + ); + + await endCustomizing(); + } + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js b/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js new file mode 100644 index 0000000000..67325b7b36 --- /dev/null +++ b/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const kTestBarID = "testBar"; +const kWidgetID = "characterencoding-button"; + +function createTestBar() { + let testBar = document.createXULElement("toolbar"); + testBar.id = kTestBarID; + testBar.setAttribute("customizable", "true"); + CustomizableUI.registerArea(kTestBarID, { + type: CustomizableUI.TYPE_TOOLBAR, + }); + gNavToolbox.appendChild(testBar); + CustomizableUI.registerToolbarNode(testBar); + return testBar; +} + +/** + * Helper function that does the following: + * + * 1) Creates a custom toolbar and registers it + * with CustomizableUI. + * 2) Adds the widget with ID aWidgetID to that new + * toolbar. + * 3) Enters customize mode and makes sure that the + * widget is still in the right toolbar. + * 4) Exits customize mode, then removes and deregisters + * the custom toolbar. + * 5) Checks that the widget has no placement. + * 6) Re-adds and re-registers a custom toolbar with the same + * ID and options as the first one. + * 7) Enters customize mode and checks that the widget is + * properly back in the toolbar. + * 8) Exits customize mode, removes and de-registers the + * toolbar, and resets the toolbars to default. + */ +function checkRestoredPresence(aWidgetID) { + return (async function () { + let testBar = createTestBar(); + CustomizableUI.addWidgetToArea(aWidgetID, kTestBarID); + let placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is( + placement.area, + kTestBarID, + "Expected " + aWidgetID + " to be in the test toolbar" + ); + + CustomizableUI.unregisterArea(testBar.id); + testBar.remove(); + + placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is(placement, null, "Expected " + aWidgetID + " to be in the palette"); + + testBar = createTestBar(); + + await startCustomizing(); + placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is( + placement.area, + kTestBarID, + "Expected " + aWidgetID + " to be in the test toolbar" + ); + await endCustomizing(); + + CustomizableUI.unregisterArea(testBar.id); + testBar.remove(); + + await resetCustomization(); + })(); +} + +add_task(async function () { + await checkRestoredPresence("downloads-button"); + await checkRestoredPresence("characterencoding-button"); +}); diff --git a/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js b/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js new file mode 100644 index 0000000000..f36b55032d --- /dev/null +++ b/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function check_tooltips_in_navbar() { + await startCustomizing(); + let homeButtonWrapper = document.getElementById("wrapper-home-button"); + let homeButton = document.getElementById("home-button"); + is( + homeButtonWrapper.getAttribute("tooltiptext"), + homeButton.getAttribute("label"), + "the wrapper's tooltip should match the button's label" + ); + ok( + homeButtonWrapper.getAttribute("tooltiptext"), + "the button should have tooltip text" + ); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_create_button_widget.js b/browser/components/customizableui/test/browser_create_button_widget.js new file mode 100644 index 0000000000..3e90011453 --- /dev/null +++ b/browser/components/customizableui/test/browser_create_button_widget.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kButton = "test_dynamically_created_button"; +var initialLocation = gBrowser.currentURI.spec; + +add_task(async function () { + info("Check dynamically created button functionality"); + + // Let's create a simple button that will open about:addons. + let widgetSpec = { + id: kButton, + type: "button", + tooltiptext: "I am an accessible name", + onClick() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:addons"); + }, + }; + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.addWidgetToArea(kButton, CustomizableUI.AREA_NAVBAR); + ok( + !CustomizableUI.isWebExtensionWidget(kButton), + "This button should not be considered an extension widget." + ); + + // check the button's functionality in navigation bar + let button = document.getElementById(kButton); + let navBar = document.getElementById("nav-bar"); + ok(button, "Dynamically created button exists"); + ok(navBar.contains(button), "Dynamically created button is in the navbar"); + await checkButtonFunctionality(button); + + resetTabs(); + + // move the add-on button in the Panel Menu + CustomizableUI.addWidgetToArea( + kButton, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + ok( + !navBar.contains(button), + "Dynamically created button was removed from the browser bar" + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality in the Overflow Panel. + await document.getElementById("nav-bar").overflowable.show(); + var panelMenu = document.getElementById("widget-overflow-mainView"); + let buttonInPanel = panelMenu.getElementsByAttribute("id", kButton); + ok( + panelMenu.contains(button), + "Dynamically created button was added to the Panel Menu" + ); + await checkButtonFunctionality(buttonInPanel[0]); +}); + +add_task(async function asyncCleanup() { + resetTabs(); + + // reset the UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The UI is in default state again."); + + // destroy the widget + CustomizableUI.destroyWidget(kButton); +}); + +function resetTabs() { + // close all opened tabs + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.selectedTab); + } + + // restore the initial tab + BrowserTestUtils.addTab(gBrowser, initialLocation); + gBrowser.removeTab(gBrowser.selectedTab); +} + +async function checkButtonFunctionality(aButton) { + aButton.click(); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "about:addons" + ); +} diff --git a/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js new file mode 100644 index 0000000000..9377c28950 --- /dev/null +++ b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_appMenu_mainView() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS: + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + let mainViewID = "appMenu-protonMainView"; + const mainView = document.getElementById(mainViewID); + + let shownPromise = BrowserTestUtils.waitForEvent(mainView, "ViewShown"); + // Should still open the panel when Ctrl key is pressed. + EventUtils.synthesizeMouseAtCenter(PanelUI.menuButton, { ctrlKey: true }); + await shownPromise; + ok(true, "Main menu shown after button pressed"); + + // Close the main panel. + let hiddenPromise = BrowserTestUtils.waitForEvent(document, "popuphidden"); + mainView.closest("panel").hidePopup(); + await hiddenPromise; +}); + +add_task(async function test_appMenu_libraryView() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS: + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + CustomizableUI.addWidgetToArea("library-button", "nav-bar"); + const button = document.getElementById("library-button"); + await waitForElementShown(button); + + // Should still open the panel when Ctrl key is pressed. + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + const libraryView = document.getElementById("appMenu-libraryView"); + let shownPromise = BrowserTestUtils.waitForEvent(libraryView, "ViewShown"); + await shownPromise; + ok(true, "Library menu shown after button pressed"); + + // Close the Library panel. + let hiddenPromise = BrowserTestUtils.waitForEvent(document, "popuphidden"); + libraryView.closest("panel").hidePopup(); + await hiddenPromise; +}); diff --git a/browser/components/customizableui/test/browser_currentset_post_reset.js b/browser/components/customizableui/test/browser_currentset_post_reset.js new file mode 100644 index 0000000000..31bcd150b1 --- /dev/null +++ b/browser/components/customizableui/test/browser_currentset_post_reset.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function checkSpacers() { + let navbarWidgets = CustomizableUI.getWidgetIdsInArea("nav-bar"); + let currentSetWidgets = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + )._getCurrentWidgetsInContainer(document.getElementById("nav-bar")); + navbarWidgets = navbarWidgets.filter(w => CustomizableUI.isSpecialWidget(w)); + currentSetWidgets = currentSetWidgets.filter(w => + CustomizableUI.isSpecialWidget(w) + ); + Assert.deepEqual( + navbarWidgets, + currentSetWidgets, + "Should have the same 'special' widgets in currentset and placements" + ); +} + +/** + * Check that after a reset, CUI's internal bookkeeping correctly deals with flexible spacers. + */ +add_task(async function () { + await startCustomizing(); + checkSpacers(); + + CustomizableUI.addWidgetToArea( + "spring", + "nav-bar", + 4 /* Insert before the last extant spacer */ + ); + await gCustomizeMode.reset(); + checkSpacers(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_customization_context_menus.js b/browser/components/customizableui/test/browser_customization_context_menus.js new file mode 100644 index 0000000000..526b3abd1b --- /dev/null +++ b/browser/components/customizableui/test/browser_customization_context_menus.js @@ -0,0 +1,632 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const isOSX = Services.appinfo.OS === "Darwin"; + +const overflowButton = document.getElementById("nav-bar-overflow-button"); +const overflowPanel = document.getElementById("widget-overflow"); + +// Right-click on the stop/reload button should +// show a context menu with options to move it. +add_task(async function home_button_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let stopReloadButton = document.getElementById("stop-reload-button"); + EventUtils.synthesizeMouse(stopReloadButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on an empty bit of tabstrip should +// show a context menu without options to move it, +// but with tab-specific options instead. +add_task(async function tabstrip_context() { + // ensure there are tabs to reload/bookmark: + let extraTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let tabstrip = document.getElementById("tabbrowser-tabs"); + let rect = tabstrip.getBoundingClientRect(); + EventUtils.synthesizeMouse(tabstrip, rect.width - 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let closedTabsAvailable = SessionStore.getClosedTabCount() == 0; + info("Closed tabs: " + closedTabsAvailable); + let expectedEntries = [ + ["#toolbar-context-openANewTab", true], + ["---"], + ["#toolbar-context-reloadSelectedTab", true], + ["#toolbar-context-bookmarkSelectedTab", true], + ["#toolbar-context-selectAllTabs", true], + ["#toolbar-context-undoCloseTab", !closedTabsAvailable], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + BrowserTestUtils.removeTab(extraTab); +}); + +// Right-click on the title bar spacer before the tabstrip should show a +// context menu without options to move it and no tab-specific options. +add_task(async function titlebar_spacer_context() { + if (!TabsInTitlebar.enabled) { + info("Skipping test that requires tabs in the title bar."); + return; + } + + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let spacer = document.querySelector( + "#TabsToolbar .titlebar-spacer[type='pre-tabs']" + ); + EventUtils.synthesizeMouseAtCenter(spacer, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on an empty bit of extra toolbar should +// show a context menu with moving options disabled, +// and a toggle option for the extra toolbar +add_task(async function empty_toolbar_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let toolbar = createToolbarWithPlacements("880164_empty_toolbar", []); + toolbar.setAttribute("context", "toolbar-context-menu"); + toolbar.setAttribute("toolbarname", "Fancy Toolbar for Context Menu"); + EventUtils.synthesizeMouseAtCenter(toolbar, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["#toggle_880164_empty_toolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + removeCustomToolbars(); +}); + +// Right-click on the urlbar-container should +// show a context menu with disabled options to move it. +add_task(async function urlbar_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let urlBarContainer = document.getElementById("urlbar-container"); + // Need to make sure not to click within an edit field. + EventUtils.synthesizeMouse(urlBarContainer, 100, 1, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on the searchbar and moving it to the menu +// and back should move the search-container instead. +add_task(async function searchbar_context_move_to_panel_and_back() { + // This is specifically testing the addToPanel function for the search bar, so + // we have to move it to its correct position in the navigation toolbar first. + // The preference will be restored when the customizations are reset later. + Services.prefs.setBoolPref("browser.search.widget.inNavBar", true); + + let searchbar = document.getElementById("searchbar"); + // This fails if the screen resolution is small and the search bar overflows + // from the nav bar. + await gCustomizeMode.addToPanel(searchbar); + let placement = CustomizableUI.getPlacementOfWidget("search-container"); + is( + placement.area, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + "Should be in panel" + ); + + await waitForOverflowButtonShown(); + + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + let hiddenPanelPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPanelPromise; + + gCustomizeMode.addToToolbar(searchbar); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement.area, CustomizableUI.AREA_NAVBAR, "Should be in navbar"); + await gCustomizeMode.removeFromArea(searchbar); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement, null, "Should be in palette"); + CustomizableUI.reset(); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement, null, "Should be in palette"); +}); + +// Right-click on an item within the panel should +// show a context menu with options to move it. +add_task(async function context_within_panel() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "new-window-button was found"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is(overflowPanel.state, "open", "The overflow panel should still be open."); + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + + let hiddenPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPromise; + + CustomizableUI.removeWidgetFromArea("new-window-button"); +}); + +// Right-click on the stop/reload button while in customization mode +// should show a context menu with options to move it. +add_task(async function context_home_button_in_customize_mode() { + await startCustomizing(); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let stopReloadButton = document.getElementById("wrapper-stop-reload-button"); + EventUtils.synthesizeMouse(stopReloadButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", false] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; +}); + +// Right-click on an item in the palette should +// show a context menu with options to move it. +add_task(async function context_click_in_palette() { + let contextMenu = document.getElementById( + "customizationPaletteItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let openFileButton = document.getElementById("wrapper-open-file-button"); + EventUtils.synthesizeMouse(openFileButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-addToToolbar", true], + [".customize-context-addToPanel", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; +}); + +// Right-click on an item in the panel while in customization mode +// should show a context menu with options to move it. +add_task(async function context_click_in_customize_mode() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("wrapper-new-window-button"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", false], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + CustomizableUI.removeWidgetFromArea("new-window-button"); + await endCustomizing(); +}); + +// Test the toolbarbutton panel context menu in customization mode +// without opening the panel before customization mode +add_task(async function context_click_customize_mode_panel_not_opened() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + this.otherWin = await openAndLoadWindow(null, true); + + await new Promise(resolve => waitForFocus(resolve, this.otherWin)); + + await startCustomizing(this.otherWin); + + let contextMenu = this.otherWin.document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = this.otherWin.document.getElementById( + "wrapper-new-window-button" + ); + EventUtils.synthesizeMouse( + newWindowButton, + 2, + 2, + { type: "contextmenu", button: 2 }, + this.otherWin + ); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", false], + ]; + checkContextMenu(contextMenu, expectedEntries, this.otherWin); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + await endCustomizing(this.otherWin); + CustomizableUI.removeWidgetFromArea("new-window-button"); + await promiseWindowClosed(this.otherWin); + this.otherWin = null; + + await new Promise(resolve => waitForFocus(resolve, window)); +}); + +// Bug 945191 - Combined buttons show wrong context menu options +// when they are in the toolbar. +add_task(async function context_combined_buttons_toolbar() { + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await startCustomizing(); + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let zoomControls = document.getElementById("wrapper-zoom-controls"); + EventUtils.synthesizeMouse(zoomControls, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + // Execute the command to move the item from the panel to the toolbar. + let moveToToolbar = contextMenu.querySelector( + ".customize-context-moveToToolbar" + ); + moveToToolbar.doCommand(); + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + await endCustomizing(); + + zoomControls = document.getElementById("zoom-controls"); + is( + zoomControls.parentNode.id, + "nav-bar-customization-target", + "Zoom-controls should be on the nav-bar" + ); + + contextMenu = document.getElementById("toolbar-context-menu"); + shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse(zoomControls, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + await resetCustomization(); +}); + +// Bug 947586 - After customization, panel items show wrong context menu options +add_task(async function context_after_customization_panel() { + info("Check panel context menu is correct after customization"); + await startCustomizing(); + await endCustomizing(); + + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "new-window-button was found"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is(overflowPanel.state, "open", "The panel should still be open."); + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + + let hiddenPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPromise; + CustomizableUI.removeWidgetFromArea("new-window-button"); +}); + +// Bug 982027 - moving icon around removes custom context menu. +add_task(async function custom_context_menus() { + let widgetId = "custom-context-menu-toolbarbutton"; + let expectedContext = "myfancycontext"; + let widget = createDummyXULButton(widgetId, "Test ctxt menu"); + widget.setAttribute("context", expectedContext); + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_NAVBAR); + is( + widget.getAttribute("context"), + expectedContext, + "Should have context menu when added to the toolbar." + ); + + await startCustomizing(); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu in the toolbar now that we're customizing." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped when in toolbar." + ); + + let panel = document.getElementById("widget-overflow-fixed-list"); + simulateItemDrag(widget, panel); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu when in the panel." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped now that we're in the panel." + ); + + simulateItemDrag( + widget, + CustomizableUI.getCustomizationTarget(document.getElementById("nav-bar")) + ); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu when back in toolbar because we're still customizing." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped now that we're back in the toolbar." + ); + + await endCustomizing(); + is( + widget.getAttribute("context"), + expectedContext, + "Should have context menu again now that we're out of customize mode." + ); + CustomizableUI.removeWidgetFromArea(widgetId); + widget.remove(); + ok( + CustomizableUI.inDefaultState, + "Should be in default state after removing button." + ); +}); + +// Bug 1690575 - 'pin to overflow menu' and 'remove from toolbar' should be hidden +// for flexible spaces +add_task(async function flexible_space_context_menu() { + CustomizableUI.addWidgetToArea("spring", "nav-bar"); + let springs = document.querySelectorAll("#nav-bar toolbarspring"); + let lastSpring = springs[springs.length - 1]; + ok(lastSpring, "we added a spring"); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse(lastSpring, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + + if (!isOSX) { + expectedEntries.unshift(["#toggle_toolbar-menubar", true]); + } + + checkContextMenu(contextMenu, expectedEntries); + contextMenu.hidePopup(); + gCustomizeMode.removeFromArea(lastSpring); + ok(!lastSpring.parentNode, "Spring should have been removed successfully."); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js b/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js new file mode 100644 index 0000000000..78b621054c --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js @@ -0,0 +1,71 @@ +"use strict"; + +add_task(async function () { + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should not be 'pressed' outside customize mode" + ); + ok( + !PanelUI.menuButton.hasAttribute("disabled"), + "Menu button should not be disabled outside of customize mode" + ); + await startCustomizing(); + + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should be disabled in customize mode" + ); + + let contextMenu = document.getElementById( + "customizationPaletteItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("wrapper-new-window-button"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode after opening a context menu" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode after opening context menu" + ); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode after hiding a context menu" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode after hiding context menu" + ); + await endCustomizing(); + + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should not be 'pressed' after ending customize mode" + ); + ok( + !PanelUI.menuButton.hasAttribute("disabled"), + "Menu button should not be disabled after ending customize mode" + ); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_lwthemes.js b/browser/components/customizableui/test/browser_customizemode_lwthemes.js new file mode 100644 index 0000000000..3b19566ac0 --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_lwthemes.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + await startCustomizing(); + // Find the footer buttons to test. + let manageLink = document.querySelector("#customization-lwtheme-link"); + + let waitForNewTab = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons"); + manageLink.click(); + let addonsTab = await waitForNewTab; + + is(gBrowser.currentURI.spec, "about:addons", "Manage opened about:addons"); + BrowserTestUtils.removeTab(addonsTab); + + // Wait for customize mode to be re-entered now that the customize tab is + // active. This is needed for endCustomizing() to work properly. + await TestUtils.waitForCondition( + () => document.documentElement.getAttribute("customizing") == "true" + ); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_uidensity.js b/browser/components/customizableui/test/browser_customizemode_uidensity.js new file mode 100644 index 0000000000..9f22341f56 --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_uidensity.js @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PREF_UI_DENSITY = "browser.uidensity"; +const PREF_AUTO_TOUCH_MODE = "browser.touchmode.auto"; + +async function testModeMenuitem(mode, modePref) { + await startCustomizing(); + + let win = document.getElementById("main-window"); + let popupButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + + // Show the popup. + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + let item = document.getElementById( + "customization-uidensity-menuitem-" + mode + ); + let normalItem = document.getElementById( + "customization-uidensity-menuitem-normal" + ); + + is( + normalItem.getAttribute("active"), + "true", + "Normal mode menuitem should be active by default" + ); + + // Hover over the mode menuitem and wait for the event that updates the UI + // density. + let mouseoverPromise = BrowserTestUtils.waitForEvent(item, "mouseover"); + EventUtils.synthesizeMouseAtCenter(item, { type: "mouseover" }); + await mouseoverPromise; + + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem hover` + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + `UI Density pref should still be set to normal on ${mode} menuitem hover` + ); + + // Hover the normal menuitem again and check that the UI density reset to normal. + EventUtils.synthesizeMouseAtCenter(normalItem, { type: "mouseover" }); + await BrowserTestUtils.waitForCondition(() => !win.hasAttribute("uidensity")); + + ok( + !win.hasAttribute("uidensity"), + `UI Density should be reset when no longer hovering the ${mode} menuitem` + ); + + // Select the custom UI density and wait for the popup to be hidden. + let popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(item, {}); + await popupHiddenPromise; + + // Check that the click permanently changed the UI density. + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem click` + ); + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + modePref, + `UI Density pref should be set to ${mode} when clicking the ${mode} menuitem` + ); + + // Open the popup again. + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + // Check that the menuitem is still active after opening and closing the popup. + is( + item.getAttribute("active"), + "true", + `${mode} mode menuitem should be active` + ); + + // Hide the popup again. + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupHiddenPromise; + + // Check that the menuitem is still active after re-opening customize mode. + await endCustomizing(); + await startCustomizing(); + + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + is( + item.getAttribute("active"), + "true", + `${mode} mode menuitem should be active after entering and exiting customize mode` + ); + + // Click the normal menuitem and check that the density is reset. + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(normalItem, {}); + await popupHiddenPromise; + + ok( + !win.hasAttribute("uidensity"), + "UI Density should be reset when clicking the normal menuitem" + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + "UI Density pref should be set to normal." + ); + + // Show the popup and click on the mode menuitem again to test the + // reset default feature. + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(item, {}); + await popupHiddenPromise; + + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem click` + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + modePref, + `UI Density pref should be set to ${mode} when clicking the ${mode} menuitem` + ); + + await gCustomizeMode.reset(); + + ok( + !win.hasAttribute("uidensity"), + "UI Density should be reset when clicking the normal menuitem" + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + "UI Density pref should be set to normal." + ); + + await endCustomizing(); +} + +add_task(async function test_touch_mode_menuitem() { + // OSX doesn't get touch mode for now. + if (AppConstants.platform == "macosx") { + is( + document.getElementById("customization-uidensity-menuitem-touch"), + null, + "There's no touch option on Mac OSX" + ); + return; + } + + await testModeMenuitem("touch", window.gUIDensity.MODE_TOUCH); + + // Test the checkbox for automatic Touch Mode transition + // in Windows Tablet Mode. + if (AppConstants.platform == "win") { + await startCustomizing(); + + let popupButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + let checkbox = document.getElementById( + "customization-uidensity-autotouchmode-checkbox" + ); + ok(checkbox.checked, "Checkbox should be checked by default"); + + // Test toggling the checkbox. + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + false, + "Automatic Touch Mode is off when the checkbox is unchecked." + ); + + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + true, + "Automatic Touch Mode is on when the checkbox is checked." + ); + + // Test reset to defaults. + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + false, + "Automatic Touch Mode is off when the checkbox is unchecked." + ); + + await gCustomizeMode.reset(); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + true, + "Automatic Touch Mode is on when the checkbox is checked." + ); + } +}); + +add_task(async function cleanup() { + await endCustomizing(); + + Services.prefs.clearUserPref(PREF_UI_DENSITY); + Services.prefs.clearUserPref(PREF_AUTO_TOUCH_MODE); +}); diff --git a/browser/components/customizableui/test/browser_disable_commands_customize.js b/browser/components/customizableui/test/browser_disable_commands_customize.js new file mode 100644 index 0000000000..f3eb06efbe --- /dev/null +++ b/browser/components/customizableui/test/browser_disable_commands_customize.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Most commands don't make sense in customize mode. Check that they're + * disabled, so shortcuts can't activate them either. Also check that + * some basic commands (close tab/window, quit, new tab, new window) + * remain functional. + */ +add_task(async function test_disable_commands() { + let disabledCommands = ["cmd_print", "Browser:SavePage", "Browser:SendLink"]; + let enabledCommands = [ + "cmd_newNavigatorTab", + "cmd_newNavigator", + "cmd_quitApplication", + "cmd_close", + "cmd_closeWindow", + ]; + + function checkDisabled() { + for (let cmd of disabledCommands) { + is( + document.getElementById(cmd).getAttribute("disabled"), + "true", + `Command ${cmd} should be disabled` + ); + } + for (let cmd of enabledCommands) { + ok( + !document.getElementById(cmd).hasAttribute("disabled"), + `Command ${cmd} should NOT be disabled` + ); + } + } + await startCustomizing(); + + checkDisabled(); + + // Do a reset just for fun, making sure we don't accidentally + // break things: + await gCustomizeMode.reset(); + + checkDisabled(); + + await endCustomizing(); + for (let cmd of disabledCommands.concat(enabledCommands)) { + ok( + !document.getElementById(cmd).hasAttribute("disabled"), + `Command ${cmd} should NOT be disabled after customize mode` + ); + } +}); + +/** + * When buttons are connected to a command, they should not get + * disabled just because we move them. + */ +add_task(async function test_dont_disable_when_moving() { + let button = gNavToolbox.palette.querySelector("#print-button"); + ok(button.hasAttribute("command"), "Button should have a command attribute."); + await startCustomizing(); + CustomizableUI.addWidgetToArea("print-button", "nav-bar"); + await endCustomizing(); + ok( + !button.hasAttribute("disabled"), + "Should not have disabled attribute after adding the button." + ); + ok( + button.hasAttribute("command"), + "Button should still have a command attribute." + ); + + await startCustomizing(); + await gCustomizeMode.reset(); + await endCustomizing(); + ok( + !button.hasAttribute("disabled"), + "Should not have disabled attribute when resetting in customize mode" + ); + ok( + button.hasAttribute("command"), + "Button should still have a command attribute." + ); +}); diff --git a/browser/components/customizableui/test/browser_drag_outside_palette.js b/browser/components/customizableui/test/browser_drag_outside_palette.js new file mode 100644 index 0000000000..2785a08896 --- /dev/null +++ b/browser/components/customizableui/test/browser_drag_outside_palette.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that moving items from the toolbar or panel to the palette by + * dropping on the panel container (not inside the visible panel) works. + */ +add_task(async function () { + await startCustomizing(); + let panelContainer = document.getElementById("customization-panel-container"); + // Try dragging an item from the navbar: + let stopReloadButton = document.getElementById("stop-reload-button"); + let oldNavbarPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + simulateItemDrag(stopReloadButton, panelContainer); + assertAreaPlacements( + CustomizableUI.AREA_NAVBAR, + oldNavbarPlacements.filter(w => w != "stop-reload-button") + ); + ok( + stopReloadButton.closest("#customization-palette"), + "Button should be in the palette" + ); + + // Put it in the panel and try again from there: + let panelHolder = document.getElementById("customization-panelHolder"); + simulateItemDrag(stopReloadButton, panelHolder); + assertAreaPlacements(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, [ + "stop-reload-button", + ]); + + simulateItemDrag(stopReloadButton, panelContainer); + assertAreaPlacements(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, []); + + ok( + stopReloadButton.closest("#customization-palette"), + "Button should be in the palette" + ); + + // Check we can't move non-removable items like this: + let urlbar = document.getElementById("urlbar-container"); + simulateItemDrag(urlbar, panelContainer); + assertAreaPlacements( + CustomizableUI.AREA_NAVBAR, + oldNavbarPlacements.filter(w => w != "stop-reload-button") + ); +}); + +registerCleanupFunction(async function () { + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_editcontrols_update.js b/browser/components/customizableui/test/browser_editcontrols_update.js new file mode 100644 index 0000000000..9f064e521a --- /dev/null +++ b/browser/components/customizableui/test/browser_editcontrols_update.js @@ -0,0 +1,307 @@ +// This test checks that the edit command enabled state (cut/paste) is updated +// properly when the edit controls are on the toolbar, popup and not present. +// It also verifies that the performance optimiation implemented by +// updateEditUIVisibility in browser.js is applied. + +let isMac = navigator.platform.indexOf("Mac") == 0; + +function checkState(allowCut, desc, testWindow = window) { + is( + testWindow.document.getElementById("cmd_cut").getAttribute("disabled") == + "true", + !allowCut, + desc + " - cut" + ); + is( + testWindow.document.getElementById("cmd_paste").getAttribute("disabled") == + "true", + false, + desc + " - paste" + ); +} + +// Add a special controller to the urlbar and browser to listen in on when +// commands are being updated. Return a promise that resolves when 'count' +// updates have occurred. +function expectCommandUpdate(count, testWindow = window) { + return new Promise((resolve, reject) => { + let overrideController = { + supportsCommand(cmd) { + return cmd == "cmd_delete"; + }, + isCommandEnabled(cmd) { + if (!count) { + ok(false, "unexpected update"); + reject(); + } + + if (!--count) { + testWindow.gURLBar.inputField.controllers.removeControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.removeControllerAt( + 0, + overrideController + ); + resolve(true); + } + }, + }; + + if (!count) { + SimpleTest.executeSoon(() => { + testWindow.gURLBar.inputField.controllers.removeControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.removeControllerAt( + 0, + overrideController + ); + resolve(false); + }); + } + + testWindow.gURLBar.inputField.controllers.insertControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.insertControllerAt( + 0, + overrideController + ); + }); +} + +// Call this between `.select()` to make sure the selection actually changes +// and thus TextInputListener::UpdateTextInputCommands() is called. +function deselectURLBarAndSpin() { + gURLBar.inputField.setSelectionRange(0, 0); + return new Promise(setTimeout); +} + +add_task(async function test_init() { + // Put something on the clipboard to verify that the paste button is properly enabled during the test. + let clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + await new Promise(resolve => { + SimpleTest.waitForClipboard( + "Sample", + function () { + clipboardHelper.copyString("Sample"); + }, + resolve + ); + }); + + // Open and close the panel first so that it is fully initialized. + await gCUITestUtils.openMainMenu(); + await gCUITestUtils.hideMainMenu(); +}); + +// Test updating when the panel is open with the edit-controls on the panel. +// Updates should occur. +add_task(async function test_panelui_opened() { + document.commandDispatcher.unlock(); + gURLBar.focus(); + gURLBar.value = "test"; + + await gCUITestUtils.openMainMenu(); + + checkState(false, "Update when edit-controls is on panel and visible"); + + let overridePromise = expectCommandUpdate(1); + gURLBar.select(); + await overridePromise; + + checkState( + true, + "Update when edit-controls is on panel and selection changed" + ); + + overridePromise = expectCommandUpdate(0); + await gCUITestUtils.hideMainMenu(); + await overridePromise; + + // Check that updates do not occur after the panel has been closed. + checkState(true, "Update when edit-controls is on panel and hidden"); + + // Mac will update the enabled state even when the panel is closed so that + // main menubar shortcuts will work properly. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls is on panel, hidden and selection changed" + ); +}); + +// Test updating when the edit-controls are moved to the toolbar. +add_task(async function test_panelui_customize_to_toolbar() { + await startCustomizing(); + let navbar = document.getElementById("nav-bar"); + simulateItemDrag( + document.getElementById("edit-controls"), + CustomizableUI.getCustomizationTarget(navbar), + "end" + ); + await endCustomizing(); + + // updateEditUIVisibility should be called when customization ends but isn't. See bug 1359790. + updateEditUIVisibility(); + + // The URL bar may have been focused to begin with, which means + // that subsequent calls to focus it won't result in command + // updates, so we'll make sure to blur it. + gURLBar.blur(); + + let overridePromise = expectCommandUpdate(1); + gURLBar.select(); + gURLBar.focus(); + gURLBar.value = "other"; + await overridePromise; + checkState(false, "Update when edit-controls on toolbar and focused"); + + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls on toolbar and selection changed" + ); + + const kOverflowPanel = document.getElementById("widget-overflow"); + + let originalWidth = window.outerWidth; + registerCleanupFunction(async function () { + kOverflowPanel.removeAttribute("animate"); + window.resizeTo(originalWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + CustomizableUI.reset(); + }); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + navbar.hasAttribute("overflowing") && + !navbar.querySelector("edit-controls") + ); + + // Mac will update the enabled state even when the buttons are overflowing, + // so main menubar shortcuts will work properly. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls is on overflow panel, hidden and selection changed" + ); + + // Check that we get an update if we select content while the panel is open. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + await navbar.overflowable.show(); + gURLBar.select(); + await overridePromise; + + // And that we don't (except on mac) when the panel is hidden. + kOverflowPanel.hidePopup(); + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + + window.resizeTo(originalWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + + CustomizableUI.addWidgetToArea( + "edit-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + // updateEditUIVisibility should be called when customization happens but isn't. See bug 1359790. + updateEditUIVisibility(); + + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + + // Check that we get an update if we select content while the panel is open. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + await navbar.overflowable.show(); + gURLBar.select(); + await overridePromise; + + // And that we don't (except on mac) when the panel is hidden. + kOverflowPanel.hidePopup(); + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; +}); + +// Test updating when the edit-controls are moved to the palette. +add_task(async function test_panelui_customize_to_palette() { + await startCustomizing(); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(document.getElementById("edit-controls"), palette); + await endCustomizing(); + + // updateEditUIVisibility should be called when customization ends but isn't. See bug 1359790. + updateEditUIVisibility(); + + let overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.focus(); + gURLBar.value = "other"; + gURLBar.select(); + await overridePromise; + + // If the UI isn't found, the command is set to be enabled. + checkState( + true, + "Update when edit-controls is on palette, hidden and selection changed" + ); +}); + +add_task(async function finish() { + await resetCustomization(); +}); + +// Test updating in the initial state when the edit-controls are on the panel but +// have not yet been created. This needs to be done in a new window to ensure that +// other tests haven't opened the panel. +add_task(async function test_initial_state() { + let testWindow = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(testWindow); + + // For focusing the URL bar to have an effect, we need to ensure the URL bar isn't + // initially focused: + testWindow.gBrowser.selectedTab.focus(); + await TestUtils.waitForCondition(() => !testWindow.gURLBar.focused); + + let overridePromise = expectCommandUpdate(isMac, testWindow); + + testWindow.gURLBar.focus(); + testWindow.gURLBar.value = "test"; + + await overridePromise; + + // Commands won't update when no edit UI is present. They default to being + // enabled so that keyboard shortcuts will work. The real enabled state will + // be checked when shortcut is pressed. + checkState( + !isMac, + "No update when edit-controls is on panel and not visible", + testWindow + ); + + await BrowserTestUtils.closeWindow(testWindow); + await SimpleTest.promiseFocus(window); +}); diff --git a/browser/components/customizableui/test/browser_exit_background_customize_mode.js b/browser/components/customizableui/test/browser_exit_background_customize_mode.js new file mode 100644 index 0000000000..2b53405256 --- /dev/null +++ b/browser/components/customizableui/test/browser_exit_background_customize_mode.js @@ -0,0 +1,44 @@ +"use strict"; + +/** + * Tests that if customize mode is currently attached to a background + * tab, and that tab browses to a new location, that customize mode + * is detached from that tab. + */ +add_task(async function test_exit_background_customize_mode() { + let nonCustomizingTab = gBrowser.selectedTab; + + Assert.equal( + gBrowser.tabContainer.querySelector("tab[customizemode=true]"), + null, + "Should not have a tab marked as being the customize tab now." + ); + + await startCustomizing(); + is(gBrowser.tabs.length, 2, "Should have 2 tabs"); + + let custTab = gBrowser.selectedTab; + + let finishedCustomizing = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, nonCustomizingTab); + await finishedCustomizing; + + let newURL = "http://example.com/"; + BrowserTestUtils.startLoadingURIString(custTab.linkedBrowser, newURL); + await BrowserTestUtils.browserLoaded(custTab.linkedBrowser, false, newURL); + + Assert.equal( + gBrowser.tabContainer.querySelector("tab[customizemode=true]"), + null, + "Should not have a tab marked as being the customize tab now." + ); + + await startCustomizing(); + is(gBrowser.tabs.length, 3, "Should have 3 tabs now"); + + await endCustomizing(); + BrowserTestUtils.removeTab(custTab); +}); diff --git a/browser/components/customizableui/test/browser_flexible_space_area.js b/browser/components/customizableui/test/browser_flexible_space_area.js new file mode 100644 index 0000000000..f3189096de --- /dev/null +++ b/browser/components/customizableui/test/browser_flexible_space_area.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getSpringCount(area) { + return CustomizableUI.getWidgetIdsInArea(area).filter(id => + id.includes("spring") + ).length; +} + +/** + * Check that no matter where we add a flexible space, we + * never end up without a flexible space in the palette. + */ +add_task(async function test_flexible_space_addition() { + await startCustomizing(); + let palette = document.getElementById("customization-palette"); + // Make the bookmarks toolbar visible: + CustomizableUI.setToolbarVisibility(CustomizableUI.AREA_BOOKMARKS, true); + let areas = [CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS]; + if (AppConstants.platform != "macosx") { + areas.push(CustomizableUI.AREA_MENUBAR); + } + + for (let area of areas) { + let spacer = palette.querySelector("toolbarspring"); + let toolbar = document.getElementById(area); + toolbar = CustomizableUI.getCustomizationTarget(toolbar); + + let springCount = getSpringCount(area); + simulateItemDrag(spacer, toolbar); + // Check we added the spring: + is( + springCount + 1, + getSpringCount(area), + "Should now have an extra spring" + ); + + // Check there's still one in the palette: + let newSpacer = palette.querySelector("toolbarspring"); + ok(newSpacer, "Should have created a new spring"); + } +}); +registerCleanupFunction(async function asyncCleanup() { + await endCustomizing(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_help_panel_cloning.js b/browser/components/customizableui/test/browser_help_panel_cloning.js new file mode 100644 index 0000000000..4234a52cd8 --- /dev/null +++ b/browser/components/customizableui/test/browser_help_panel_cloning.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global PanelUI */ + +let gAppMenuStrings = new Localization( + ["branding/brand.ftl", "browser/appmenu.ftl"], + true +); + +const CLONED_ATTRS = ["command", "oncommand", "onclick", "key", "disabled"]; + +/** + * Tests that the Help panel inside of the AppMenu properly clones + * the items from the Help menupopup. Also ensures that the AppMenu + * string variants for those menuitems exist inside of appmenu.ftl. + */ +add_task(async function test_help_panel_cloning() { + await gCUITestUtils.openMainMenu(); + registerCleanupFunction(async () => { + await gCUITestUtils.hideMainMenu(); + }); + + // Showing the Help panel should be enough to get the menupopup to + // populate itself. + let anchor = document.getElementById("PanelUI-menu-button"); + PanelUI.showHelpView(anchor); + + let appMenuHelpSubview = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(appMenuHelpSubview, "ViewShowing"); + + let helpMenuPopup = document.getElementById("menu_HelpPopup"); + let helpMenuPopupItems = helpMenuPopup.querySelectorAll("menuitem"); + + for (let helpMenuPopupItem of helpMenuPopupItems) { + if (helpMenuPopupItem.hidden) { + continue; + } + + let appMenuHelpId = "appMenu_" + helpMenuPopupItem.id; + info(`Checking ${appMenuHelpId}`); + + let appMenuHelpItem = appMenuHelpSubview.querySelector(`#${appMenuHelpId}`); + Assert.ok(appMenuHelpItem, "Should have found a cloned AppMenu help item"); + + let appMenuHelpItemL10nId = appMenuHelpItem.dataset.l10nId; + // There is a convention that the Help menu item should have an + // appmenu-data-l10n-id attribute set as the AppMenu-specific localization + // id. + Assert.equal( + helpMenuPopupItem.getAttribute("appmenu-data-l10n-id"), + appMenuHelpItemL10nId, + "Help menuitem supplied a data-l10n-id for the AppMenu Help item" + ); + + let [strings] = gAppMenuStrings.formatMessagesSync([ + { id: appMenuHelpItemL10nId }, + ]); + Assert.ok(strings, "Should have found strings for the AppMenu help item"); + + // Make sure the CLONED_ATTRs are actually cloned. + for (let attr of CLONED_ATTRS) { + if (attr == "oncommand" && helpMenuPopupItem.hasAttribute("command")) { + // If the original element had a "command" attribute set, then the + // cloned element will have its "oncommand" attribute set to equal + // the "oncommand" attribute of the <command> pointed to via the + // original's "command" attribute once it is inserted into the DOM. + // + // This is by virtue of the broadcasting ability of XUL <command> + // elements. + let commandNode = document.getElementById( + helpMenuPopupItem.getAttribute("command") + ); + Assert.equal( + commandNode.getAttribute("oncommand"), + appMenuHelpItem.getAttribute("oncommand"), + "oncommand was properly cloned." + ); + } else { + Assert.equal( + helpMenuPopupItem.getAttribute(attr), + appMenuHelpItem.getAttribute(attr), + `${attr} attribute was cloned.` + ); + } + } + } +}); diff --git a/browser/components/customizableui/test/browser_hidden_widget_overflow.js b/browser/components/customizableui/test/browser_hidden_widget_overflow.js new file mode 100644 index 0000000000..eff0bff4b0 --- /dev/null +++ b/browser/components/customizableui/test/browser_hidden_widget_overflow.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if only hidden widgets are overflowed that the + * OverflowableToolbar won't show the overflow panel anchor. + */ + +const kHiddenButtonID = "fake-hidden-button"; +const kDisplayNoneButtonID = "display-none-button"; +const kWebExtensionButtonID1 = "fake-webextension-button-1"; +const kWebExtensionButtonID2 = "fake-webextension-button-2"; +let gWin = null; + +add_setup(async function () { + gWin = await BrowserTestUtils.openNewBrowserWindow(); + + // To make it easier to write a test where we can control overflowing + // for a test that can run in a bunch of environments with slightly + // different rules on when things will overflow, we'll go ahead and + // just remove everything removable from the nav-bar by default. Then + // we'll add our hidden item, and a single WebExtension item, and + // force toolbar overflow. + let widgetIDs = CustomizableUI.getWidgetIdsInArea(CustomizableUI.AREA_NAVBAR); + for (let widgetID of widgetIDs) { + if (CustomizableUI.isWidgetRemovable(widgetID)) { + CustomizableUI.removeWidgetFromArea(widgetID); + } + } + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID1, + label: "Test WebExtension widget 1", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID2, + label: "Test WebExtension widget 2", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + // Let's force the WebExtension widgets to be significantly wider. This + // just makes it easier to ensure that both of these (which are to the left + // of the hidden widget) get overflowed. + for (let webExtID of [kWebExtensionButtonID1, kWebExtensionButtonID2]) { + let webExtNode = CustomizableUI.getWidget(webExtID).forWindow(gWin).node; + webExtNode.style.minWidth = "100px"; + } + + CustomizableUI.createWidget({ + id: kHiddenButtonID, + label: "Test hidden=true widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + // Now hide the button with hidden=true so that it has no dimensions. + let hiddenButtonNode = + CustomizableUI.getWidget(kHiddenButtonID).forWindow(gWin).node; + hiddenButtonNode.hidden = true; + + CustomizableUI.createWidget({ + id: kDisplayNoneButtonID, + label: "Test display:none widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + // Now hide the button with display: none so that it has no dimensions. + let displayNoneButtonNode = + CustomizableUI.getWidget(kDisplayNoneButtonID).forWindow(gWin).node; + displayNoneButtonNode.style.display = "none"; + + registerCleanupFunction(async () => { + CustomizableUI.destroyWidget(kWebExtensionButtonID1); + CustomizableUI.destroyWidget(kWebExtensionButtonID2); + CustomizableUI.destroyWidget(kHiddenButtonID); + CustomizableUI.destroyWidget(kDisplayNoneButtonID); + await BrowserTestUtils.closeWindow(gWin); + await CustomizableUI.reset(); + }); +}); + +add_task(async function test_hidden_widget_overflow() { + gWin.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + // Wait until the left-most fake WebExtension button is overflowing. + let webExtNode = CustomizableUI.getWidget(kWebExtensionButtonID1).forWindow( + gWin + ).node; + await BrowserTestUtils.waitForMutationCondition( + webExtNode, + { attributes: true }, + () => { + return webExtNode.hasAttribute("overflowedItem"); + } + ); + + let hiddenButtonNode = + CustomizableUI.getWidget(kHiddenButtonID).forWindow(gWin).node; + Assert.ok( + hiddenButtonNode.hasAttribute("overflowedItem"), + "Hidden button should be overflowed." + ); + + let overflowButton = gWin.document.getElementById("nav-bar-overflow-button"); + + Assert.ok( + !BrowserTestUtils.isVisible(overflowButton), + "Overflow panel button should be hidden." + ); +}); diff --git a/browser/components/customizableui/test/browser_history_after_appMenu.js b/browser/components/customizableui/test/browser_history_after_appMenu.js new file mode 100644 index 0000000000..89c4b467a2 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_after_appMenu.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Checks that opening the History view using the default toolbar button works + * also while the view is displayed in the main menu. + */ +add_task(async function test_history_after_appMenu() { + // First add the button to the toolbar and wait for it to show up: + CustomizableUI.addWidgetToArea("history-panelmenu", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("history-panelmenu") + ); + await waitForElementShown(document.getElementById("history-panelmenu")); + + let historyView = PanelMultiView.getViewNode(document, "PanelUI-history"); + // Open the main menu. + await gCUITestUtils.openMainMenu(); + + // Show the History view as a subview of the main menu. + document.getElementById("appMenu-history-button").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Show the History view as the main view of the History panel. + document.getElementById("history-panelmenu").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Close the history panel. + let historyPanel = historyView.closest("panel"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "popuphidden"); + historyPanel.hidePopup(); + await promise; +}); diff --git a/browser/components/customizableui/test/browser_history_recently_closed.js b/browser/components/customizableui/test/browser_history_recently_closed.js new file mode 100644 index 0000000000..32c75ec8e2 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_recently_closed.js @@ -0,0 +1,430 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { SessionStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SessionStoreTestUtils.sys.mjs" +); +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +SessionStoreTestUtils.init(this, window); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +let panelMenuWidgetAdded = false; +function prepareHistoryPanel() { + if (panelMenuWidgetAdded) { + return; + } + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); +} + +async function openRecentlyClosedTabsMenu() { + prepareHistoryPanel(); + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button enabled" + ); + let closeTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + let panelView = closeTabsPanel && PanelView.forNode(closeTabsPanel); + if (!panelView?.active) { + recentlyClosedTabs.click(); + closeTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + await BrowserTestUtils.waitForEvent(closeTabsPanel, "ViewShown"); + ok( + PanelView.forNode(closeTabsPanel)?.active, + "Opened 'Recently closed tabs' panel" + ); + } + + return closeTabsPanel; +} + +function resetClosedTabsAndWindows() { + // Clear the lists of closed windows and tabs. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is(SessionStore.getClosedWindowCount(), 0, "Expect 0 closed windows"); + for (const win of BrowserWindowTracker.orderedWindows) { + is( + SessionStore.getClosedTabCountForWindow(win), + 0, + "Expect 0 closed tabs for this window" + ); + } +} + +registerCleanupFunction(async () => { + await resetClosedTabsAndWindows(); +}); + +add_task(async function testRecentlyClosedDisabled() { + info("Check history recently closed tabs/windows section"); + + prepareHistoryPanel(); + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + let recentlyClosedWindows = document.getElementById( + "appMenuRecentlyClosedWindows" + ); + + // Wait for the disabled attribute to change, as we receive + // the "viewshown" event before this changes + await BrowserTestUtils.waitForCondition( + () => recentlyClosedTabs.getAttribute("disabled"), + "Waiting for button to become disabled" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button disabled" + ); + Assert.ok( + recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows button disabled" + ); + + await hideHistoryPanel(); + + gBrowser.selectedTab.focus(); + await SessionStoreTestUtils.openAndCloseTab( + window, + TEST_PATH + "dummy_history_item.html" + ); + + await openHistoryPanel(); + + await BrowserTestUtils.waitForCondition( + () => !recentlyClosedTabs.getAttribute("disabled"), + "Waiting for button to be enabled" + ); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is available" + ); + Assert.ok( + recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows button disabled" + ); + + await hideHistoryPanel(); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let loadedPromise = BrowserTestUtils.browserLoaded( + newWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + newWin.gBrowser.selectedBrowser, + "about:mozilla" + ); + await loadedPromise; + await BrowserTestUtils.closeWindow(newWin); + + await openHistoryPanel(); + + await BrowserTestUtils.waitForCondition( + () => !recentlyClosedWindows.getAttribute("disabled"), + "Waiting for button to be enabled" + ); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is available" + ); + Assert.ok( + !recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows is available" + ); + + await hideHistoryPanel(); +}); + +add_task(async function testRecentlyClosedTabsDisabledPersists() { + info("Check history recently closed tabs/windows section"); + + prepareHistoryPanel(); + + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button disabled" + ); + + await hideHistoryPanel(); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + await openHistoryPanel(newWin.document); + recentlyClosedTabs = newWin.document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is disabled" + ); + + // We close the window without hiding the panel first, which used to interfere + // with populating the view subsequently. + await BrowserTestUtils.closeWindow(newWin); + + newWin = await BrowserTestUtils.openNewBrowserWindow(); + await openHistoryPanel(newWin.document); + recentlyClosedTabs = newWin.document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is disabled" + ); + await hideHistoryPanel(newWin.document); + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function testRecentlyClosedRestoreAllTabs() { + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + await resetClosedTabsAndWindows(); + const initialTabCount = gBrowser.visibleTabs.length; + + const closedTabUrls = [ + "about:robots", + "https://example.com/", + "https://example.org/", + ]; + const windowState = { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + _closedTabs: closedTabUrls.map(url => { + return { + title: url, + state: { + entries: [ + { + url, + triggeringPrincipal_base64, + }, + ], + }, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState({ + windows: [windowState], + }); + + is(gBrowser.visibleTabs.length, 1, "We start with one tab open"); + // Open the "Recently closed tabs" panel. + let closeTabsPanel = await openRecentlyClosedTabsMenu(); + + // Click the first toolbar button in the panel. + let toolbarButton = closeTabsPanel.querySelector( + ".panel-subview-body toolbarbutton" + ); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + EventUtils.sendMouseEvent({ type: "click" }, toolbarButton, window); + + info( + "We should reopen the first of closedTabUrls: " + + JSON.stringify(closedTabUrls) + ); + let reopenedTab = await newTabPromise; + is( + reopenedTab.linkedBrowser.currentURI.spec, + closedTabUrls[0], + "Opened the first URL" + ); + info(`restored tab, total open tabs: ${gBrowser.tabs.length}`); + + info("waiting for closeTab"); + await SessionStoreTestUtils.closeTab(reopenedTab); + + await openRecentlyClosedTabsMenu(); + let restoreAllItem = closeTabsPanel.querySelector(".restoreallitem"); + ok( + restoreAllItem && !restoreAllItem.hidden, + "Restore all menu item is not hidden" + ); + + // Click the restore-all toolbar button in the panel. + EventUtils.sendMouseEvent({ type: "click" }, restoreAllItem, window); + + info("waiting for restored tabs"); + await BrowserTestUtils.waitForCondition( + () => SessionStore.getClosedTabCount() === 0, + "Waiting for all the closed tabs to be opened" + ); + + is( + gBrowser.tabs.length, + initialTabCount + closedTabUrls.length, + "The expected number of closed tabs were restored" + ); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +add_task(async function testRecentlyClosedWindows() { + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + await resetClosedTabsAndWindows(); + + // Open and close a new window. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let loadedPromise = BrowserTestUtils.browserLoaded( + newWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + newWin.gBrowser.selectedBrowser, + "https://example.com" + ); + await loadedPromise; + let closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + await BrowserTestUtils.closeWindow(newWin); + await closedObjectsChangePromise; + + prepareHistoryPanel(); + await openHistoryPanel(); + + // Open the "Recently closed windows" panel. + document.getElementById("appMenuRecentlyClosedWindows").click(); + + let winPanel = document.getElementById( + "appMenu-library-recentlyClosedWindows" + ); + await BrowserTestUtils.waitForEvent(winPanel, "ViewShown"); + ok(true, "Opened 'Recently closed windows' panel"); + + // Click the first toolbar button in the panel. + let panelBody = winPanel.querySelector(".panel-subview-body"); + let toolbarButton = panelBody.querySelector("toolbarbutton"); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/", + }); + closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.sendMouseEvent({ type: "click" }, toolbarButton, window); + + newWin = await newWindowPromise; + await closedObjectsChangePromise; + is(gBrowser.tabs.length, 1, "Did not open new tabs"); + + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function testRecentlyClosedTabsFromClosedWindows() { + await resetClosedTabsAndWindows(); + const closedTabUrls = [ + "about:robots", + "https://example.com/", + "https://example.org/", + ]; + const closedWindowState = { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + _closedTabs: closedTabUrls.map(url => { + return { + title: url, + state: { + entries: [ + { + url, + triggeringPrincipal_base64, + }, + ], + }, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + }, + ], + _closedWindows: [closedWindowState], + }); + Assert.equal( + SessionStore.getClosedTabCountFromClosedWindows(), + closedTabUrls.length, + "Sanity check number of closed tabs from closed windows" + ); + + prepareHistoryPanel(); + let closeTabsPanel = await openRecentlyClosedTabsMenu(); + // make sure we can actually restore one of these closed tabs + const closedTabItems = closeTabsPanel.querySelectorAll( + "toolbarbutton[targetURI]" + ); + Assert.equal( + closedTabItems.length, + closedTabUrls.length, + "We have expected number of closed tab items" + ); + + const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + const closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.sendMouseEvent({ type: "click" }, closedTabItems[0], window); + await newTabPromise; + await closedObjectsChangePromise; + + // flip the pref so none of the closed tabs from closed window are included + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromClosedWindows", false]], + }); + await openHistoryPanel(); + + // verify the recently-closed-tabs menu item is disabled + let recentlyClosedTabsItem = document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabsItem.hasAttribute("disabled"), + "Recently closed tabs button is now disabled" + ); + SpecialPowers.popPrefEnv(); + while (gBrowser.tabs.length > 1) { + await SessionStoreTestUtils.closeTab( + gBrowser.tabs[gBrowser.tabs.length - 1] + ); + } +}); diff --git a/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js b/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js new file mode 100644 index 0000000000..ee2656ebca --- /dev/null +++ b/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verifies that middle-clicking "Recently Closed Tabs" in both history +// menus works as expected. + +const URLS = [ + "http://example.com/", + "http://example.org/", + "http://example.net/", +]; + +async function setupTest() { + // Navigate the initial tab to ensure that it won't be reused for the tab + // that will be reopened. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com" + ); + await loadPromise; + + // Populate the recently closed tabs list. + for (let url of URLS) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + for (let i = 0; i < URLS.length; i++) { + gBrowser.removeTab(gBrowser.selectedTab); + } + + return gBrowser.tabs.length; +} + +add_task(async function testMenubar() { + if (AppConstants.platform === "macosx") { + ok(true, "Can't open menu items on macOS"); + return; + } + + let nOpenTabs = await setupTest(); + + // Open the "History" menu. + let menu = document.getElementById("history-menu"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + menu.open = true; + await popupPromise; + ok(true, "Opened 'History' menu"); + + // Open the "Recently Closed Tabs" submenu. + let undoMenu = document.getElementById("historyUndoMenu"); + popupPromise = BrowserTestUtils.waitForEvent(undoMenu, "popupshown"); + undoMenu.open = true; + let popupEvent = await popupPromise; + ok(true, "Opened 'Recently Closed Tabs' menu"); + + // And now middle-click the first item in that menu, and ensure that we're + // only opening a single new tab. + let menuitems = popupEvent.target.querySelectorAll("menuitem"); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + popupEvent.target.activateItem(menuitems[0], { button: 1 }); + + let newTab = await newTabPromise; + is(newTab.linkedBrowser.currentURI.spec, URLS[0], "Opened correct URL"); + is(gBrowser.tabs.length, nOpenTabs + 1, "Only opened 1 new tab"); + + gBrowser.removeTab(newTab); +}); + +add_task(async function testHistoryPanel() { + let nOpenTabs = await setupTest(); + + // Setup history panel. + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + await openHistoryPanel(); + + // Open the "Recently closed tabs" panel. + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + recentlyClosedTabs.click(); + + let recentlyClosedTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + await BrowserTestUtils.waitForEvent(recentlyClosedTabsPanel, "ViewShown"); + ok(true, "Opened 'Recently closed tabs' panel"); + + let panelBody = recentlyClosedTabsPanel.querySelector(".panel-subview-body"); + let toolbarButtons = panelBody.querySelectorAll("toolbarbutton"); + + // Middle-click the first toolbar button in the panel. + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + toolbarButtons[0], + window + ); + + let newTab = await newTabPromise; + is(newTab.linkedBrowser.currentURI.spec, URLS[0], "Opened correct URL"); + is(gBrowser.tabs.length, nOpenTabs + 1, "Only opened 1 new tab"); + + gBrowser.removeTab(newTab); +}); diff --git a/browser/components/customizableui/test/browser_history_restore_session.js b/browser/components/customizableui/test/browser_history_restore_session.js new file mode 100644 index 0000000000..a8b9529209 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_restore_session.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function testRestoreSession() { + info("Check history panel's restore previous session button"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(win.document); + + let restorePrevSessionBtn = win.document.getElementById( + "appMenu-restoreSession" + ); + + Assert.ok( + restorePrevSessionBtn.hidden, + "Restore previous session button is not visible" + ); + await hideHistoryPanel(win.document); + + BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + await BrowserTestUtils.closeWindow(win); + + win = await BrowserTestUtils.openNewBrowserWindow(); + + let lastSession = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" + )._LastSession; + lastSession.setState(true); + + await openHistoryPanel(win.document); + + restorePrevSessionBtn = win.document.getElementById("appMenu-restoreSession"); + Assert.ok( + !restorePrevSessionBtn.hidden, + "Restore previous session button is visible" + ); + + await hideHistoryPanel(win.document); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_insert_before_moved_node.js b/browser/components/customizableui/test/browser_insert_before_moved_node.js new file mode 100644 index 0000000000..611f4e3ce0 --- /dev/null +++ b/browser/components/customizableui/test/browser_insert_before_moved_node.js @@ -0,0 +1,51 @@ +"use strict"; + +/** + * Check inserting before a node that has moved from the toolbar into a + * non-customizable bit of the browser works. + */ +add_task(async function () { + for (let toolbar of ["nav-bar", "TabsToolbar"]) { + CustomizableUI.createWidget({ + id: "real-button", + label: "test real button", + }); + CustomizableUI.addWidgetToArea("real-button", toolbar); + CustomizableUI.addWidgetToArea("moved-button-not-here", toolbar); + let placements = CustomizableUI.getWidgetIdsInArea(toolbar); + Assert.deepEqual( + placements.slice(-2), + ["real-button", "moved-button-not-here"], + "Should have correct placements" + ); + let otherButton = document.createXULElement("toolbarbutton"); + otherButton.id = "moved-button-not-here"; + if (toolbar == "nav-bar") { + gURLBar.textbox.parentNode.appendChild(otherButton); + } else { + gBrowser.tabContainer.appendChild(otherButton); + } + CustomizableUI.destroyWidget("real-button"); + CustomizableUI.createWidget({ + id: "real-button", + label: "test real button", + }); + + let button = document.getElementById("real-button"); + ok(button, "Button should exist"); + if (button) { + let expectedContainer = CustomizableUI.getCustomizationTarget( + document.getElementById(toolbar) + ); + is( + button.parentNode, + expectedContainer, + "Button should be in the toolbar" + ); + } + + CustomizableUI.destroyWidget("real-button"); + otherButton.remove(); + CustomizableUI.reset(); + } +}); diff --git a/browser/components/customizableui/test/browser_menubar_visibility.js b/browser/components/customizableui/test/browser_menubar_visibility.js new file mode 100644 index 0000000000..82f2959905 --- /dev/null +++ b/browser/components/customizableui/test/browser_menubar_visibility.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that menubar visibility is propagated correctly to new windows. + */ +add_task(async function test_menubar_visbility() { + let menubar = document.getElementById("toolbar-menubar"); + is(menubar.getAttribute("autohide"), "true", "Menubar should be autohiding"); + registerCleanupFunction(() => { + Services.xulStore.removeValue( + AppConstants.BROWSER_CHROME_URL, + menubar.id, + "autohide" + ); + menubar.setAttribute("autohide", "true"); + }); + + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse( + document.getElementById("stop-reload-button"), + 2, + 2, + { + type: "contextmenu", + button: 2, + } + ); + await shownPromise; + let attrChanged = BrowserTestUtils.waitForAttribute( + "autohide", + menubar, + "false" + ); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("toggle_toolbar-menubar"), + {} + ); + await attrChanged; + contextMenu.hidePopup(); // to be safe. + + is( + menubar.getAttribute("autohide"), + "false", + "Menubar should now be permanently visible." + ); + let persistedValue = Services.xulStore.getValue( + AppConstants.BROWSER_CHROME_URL, + menubar.id, + "autohide" + ); + is(persistedValue, "false", "New value should be persisted"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + is( + win.document.getElementById("toolbar-menubar").getAttribute("autohide"), + "false", + "Menubar should also be permanently visible in the new window." + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_newtab_button_customizemode.js b/browser/components/customizableui/test/browser_newtab_button_customizemode.js new file mode 100644 index 0000000000..c30616f3a3 --- /dev/null +++ b/browser/components/customizableui/test/browser_newtab_button_customizemode.js @@ -0,0 +1,181 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests in this file check that user customizations to the tabstrip show + * the correct type of new tab button while the tabstrip isn't overflowing. + */ + +const kGlobalNewTabButton = document.getElementById("new-tab-button"); +const kInnerNewTabButton = gBrowser.tabContainer.newTabButton; + +function assertNewTabButton(which) { + if (which == "global") { + isnot( + kGlobalNewTabButton.getBoundingClientRect().width, + 0, + "main new tab button should be visible" + ); + is( + kInnerNewTabButton.getBoundingClientRect().width, + 0, + "inner new tab button should be hidden" + ); + } else if (which == "inner") { + is( + kGlobalNewTabButton.getBoundingClientRect().width, + 0, + "main new tab button should be hidden" + ); + isnot( + kInnerNewTabButton.getBoundingClientRect().width, + 0, + "inner new tab button should be visible" + ); + } else { + ok(false, "Unexpected button: " + which); + } +} + +/** + * Add and remove items *after* the new tab button in customize mode. + */ +add_task(async function addremove_after_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("inner"); + + await startCustomizing(); + let dropTarget = document.getElementById("forward-button"); + await waitForElementShown(dropTarget); + simulateItemDrag( + document.getElementById("stop-reload-button"), + dropTarget, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should still have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *before* the new tab button in customize mode. + */ +add_task(async function addremove_before_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "start" + ); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("global"); + await startCustomizing(); + let dropTarget = document.getElementById("forward-button"); + await waitForElementShown(dropTarget); + simulateItemDrag( + document.getElementById("stop-reload-button"), + dropTarget, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *after* the new tab button outside of customize mode. + */ +add_task(async function addremove_after_newtab_api() { + CustomizableUI.addWidgetToArea("stop-reload-button", "TabsToolbar"); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute" + ); + assertNewTabButton("inner"); + + CustomizableUI.reset(); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should still have the adjacent newtab attribute" + ); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *before* the new tab button outside of customize mode. + */ +add_task(async function addremove_before_newtab_api() { + let index = + CustomizableUI.getWidgetIdsInArea("TabsToolbar").indexOf("new-tab-button"); + CustomizableUI.addWidgetToArea("stop-reload-button", "TabsToolbar", index); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + assertNewTabButton("global"); + + CustomizableUI.removeWidgetFromArea("stop-reload-button"); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + assertNewTabButton("inner"); + + CustomizableUI.reset(); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Reset to defaults in customize mode to see if that doesn't break things. + */ +add_task(async function reset_before_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "start" + ); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("global"); + await startCustomizing(); + await gCustomizeMode.reset(); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); diff --git a/browser/components/customizableui/test/browser_open_from_popup.js b/browser/components/customizableui/test/browser_open_from_popup.js new file mode 100644 index 0000000000..bf140fde79 --- /dev/null +++ b/browser/components/customizableui/test/browser_open_from_popup.js @@ -0,0 +1,24 @@ +"use strict"; + +/** + * Check that opening customize mode in a popup opens it in the main window. + */ +add_task(async function open_customize_mode_from_popup() { + let promiseWindow = BrowserTestUtils.waitForNewWindow(); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.window.open("about:blank", "_blank", "height=300,toolbar=no"); + }); + let win = await promiseWindow; + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + win.gCustomizeMode.enter(); + await customizePromise; + ok( + document.documentElement.hasAttribute("customizing"), + "Should have opened customize mode in the parent window" + ); + await endCustomizing(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_open_in_lazy_tab.js b/browser/components/customizableui/test/browser_open_in_lazy_tab.js new file mode 100644 index 0000000000..c18de67698 --- /dev/null +++ b/browser/components/customizableui/test/browser_open_in_lazy_tab.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * Check that customize mode can be loaded in a lazy tab. + */ +add_task(async function open_customize_mode_in_lazy_tab() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + createLazyBrowser: true, + }); + gCustomizeMode.setTab(tab); + + is(tab.linkedPanel, "", "Tab should be lazy"); + + let title = gNavigatorBundle.getFormattedString("customizeMode.tabTitle", [ + document.getElementById("bundle_brand").getString("brandShortName"), + ]); + is(tab.label, title, "Tab should have correct title"); + + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizePromise; + + is( + tab.getAttribute("customizemode"), + "true", + "Tab should be in customize mode" + ); + + let customizationContainer = document.getElementById( + "customization-container" + ); + is( + customizationContainer.hidden, + false, + "Customization container should be visible" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_overflow_use_subviews.js b/browser/components/customizableui/test/browser_overflow_use_subviews.js new file mode 100644 index 0000000000..1e6e227364 --- /dev/null +++ b/browser/components/customizableui/test/browser_overflow_use_subviews.js @@ -0,0 +1,88 @@ +"use strict"; + +const kOverflowPanel = document.getElementById("widget-overflow"); + +var gOriginalWidth; +async function stopOverflowing() { + kOverflowPanel.removeAttribute("animate"); + window.resizeTo(gOriginalWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !document.getElementById("nav-bar").hasAttribute("overflowing") + ); + CustomizableUI.reset(); +} + +registerCleanupFunction(stopOverflowing); + +/** + * This checks that subview-compatible items show up as subviews rather than + * re-anchored panels. If we ever remove the library widget, please + * replace this test with another subview - don't remove it. + */ +add_task(async function check_library_subview_in_overflow() { + kOverflowPanel.setAttribute("animate", "false"); + gOriginalWidth = window.outerWidth; + + CustomizableUI.addWidgetToArea("library-button", CustomizableUI.AREA_NAVBAR); + + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = BrowserTestUtils.waitForEvent( + kOverflowPanel, + "ViewShown" + ); + chevron.click(); + await shownPanelPromise; + + let button = document.getElementById("library-button"); + button.click(); + + let libraryView = document.getElementById("appMenu-libraryView"); + await BrowserTestUtils.waitForEvent(libraryView, "ViewShown"); + let hasSubviews = !!kOverflowPanel.querySelector("panelmultiview"); + let expectedPanel = hasSubviews + ? kOverflowPanel + : document.getElementById("customizationui-widget-panel"); + is(libraryView.closest("panel"), expectedPanel, "Should be inside the panel"); + expectedPanel.hidePopup(); + await Promise.resolve(); // wait for popup to hide fully. + await stopOverflowing(); +}); + +/** + * This checks that non-subview-compatible items still work correctly. + * Ideally we should make the downloads panel and bookmarks/library item + * proper subview items, then this test can go away, and potentially we can + * simplify some of the subview anchoring code. + */ +add_task(async function check_downloads_panel_in_overflow() { + let button = document.getElementById("downloads-button"); + await gCustomizeMode.addToPanel(button); + await waitForOverflowButtonShown(); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = promisePanelElementShown(window, kOverflowPanel); + chevron.click(); + await shownPanelPromise; + + button.click(); + await TestUtils.waitForCondition(() => { + let panel = document.getElementById("downloadsPanel"); + return panel && panel.state != "closed"; + }); + let downloadsPanel = document.getElementById("downloadsPanel"); + isnot( + downloadsPanel.state, + "closed", + "Should be attempting to show the downloads panel." + ); + downloadsPanel.hidePopup(); +}); diff --git a/browser/components/customizableui/test/browser_palette_labels.js b/browser/components/customizableui/test/browser_palette_labels.js new file mode 100644 index 0000000000..42767a8ee2 --- /dev/null +++ b/browser/components/customizableui/test/browser_palette_labels.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that all customizable buttons have labels and icons. + * + * This is primarily designed to ensure we don't end up with items without + * labels in customize mode. In the past, this has happened due to race + * conditions, where labels would be correct if and only if the item had + * already been moved into a toolbar or panel in the main UI before + * (forcing it to be constructed and any fluent identifiers to be localized + * and applied). + * We use a new window to ensure that earlier tests using some of the widgets + * in the palette do not influence our checks to see that such items get + * labels, "even" if the first time they're rendered is in customize mode's + * palette. + */ +add_task(async function test_all_buttons_have_labels() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await endCustomizing(win); + return BrowserTestUtils.closeWindow(win); + }); + await startCustomizing(win); + let { palette } = win.gNavToolbox; + // Wait for things to paint. + await TestUtils.waitForCondition(() => { + return !!Array.from(palette.querySelectorAll(".toolbarbutton-icon")).filter( + n => { + let rect = n.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + } + ).length; + }, "Must start rendering icons."); + + for (let wrapper of palette.children) { + if (wrapper.hasAttribute("title")) { + ok(true, wrapper.firstElementChild.id + " has a label."); + } else { + info( + `${wrapper.firstElementChild.id} doesn't seem to have a label, waiting.` + ); + await BrowserTestUtils.waitForAttribute("title", wrapper); + ok( + wrapper.hasAttribute("title"), + wrapper.firstElementChild.id + " has a label." + ); + } + let icons = Array.from(wrapper.querySelectorAll(".toolbarbutton-icon")); + // If there are icons, at least one must be visible + // (not everything necessarily has one, e.g. the search bar has no icon) + if (icons.length) { + let visibleIcons = icons.filter(n => { + let rect = n.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + }); + Assert.greater( + visibleIcons.length, + 0, + `${wrapper.firstElementChild.id} should have at least one visible icon.` + ); + } + } +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications.js b/browser/components/customizableui/test/browser_panelUINotifications.js new file mode 100644 index 0000000000..818fcbad39 --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications.js @@ -0,0 +1,597 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * Tests that when we click on the main call-to-action of the doorhanger, the provided + * action is called, and the doorhanger removed. + */ +add_task(async function testMainActionCalled() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let button = doorhanger.button; + button.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * This tests that when we click the secondary action for a notification, + * it will display the badge for that notification on the PanelUI menu button. + * Once we click on this button, we should see an item in the menu which will + * call our main action. + */ +add_task(async function testSecondaryActionWorkflow() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + await gCUITestUtils.hideMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is shown on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + AppMenuNotifications.removeNotification(/.*/); + }); +}); + +/** + * This tests that the PanelUI update downloading badge and banner + * notification are correctly displayed and that clicking the banner + * item calls the main action. + */ +add_task(async function testDownloadingBadge() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + // The downloading notification is always displayed in a dismissed state. + AppMenuNotifications.showNotification( + "update-downloading", + mainAction, + undefined, + { dismissed: true } + ); + is(PanelUI.notificationPanel.state, "closed", "doorhanger is closed."); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-downloading", + "Showing correct label (downloading)" + ); + is(menuItem.hidden, false, "update-downloading menu item is showing."); + + await gCUITestUtils.hideMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is shown on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + AppMenuNotifications.removeNotification(/.*/); + }); +}); + +/** + * We want to ensure a few things with this: + * - Adding a doorhanger will make a badge disappear + * - once the notification for the doorhanger is resolved (removed, not just dismissed), + * then we display any other badges that are remaining. + */ +add_task(async function testInteractionWithBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + // Remove the fxa toolbar button from the navbar to ensure the notification + // is displayed on the app menu button. + let { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" + ); + CustomizableUI.removeWidgetFromArea("fxa-toolbar-menu-button"); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is hidden on PanelUI button." + ); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + AppMenuNotifications.removeNotification(/.*/); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * This tests that adding a badge will not dismiss any existing doorhangers. + */ +add_task(async function testAddingBadgeWhileDoorhangerIsShowing() { + await BrowserTestUtils.withNewTab("about:blank", function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is hidden on PanelUI button." + ); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let mainActionButton = doorhanger.button; + mainActionButton.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + AppMenuNotifications.removeNotification(/.*/); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that badges operate like a stack. + */ +add_task(async function testMultipleBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + let doc = browser.ownerDocument; + let menuButton = doc.getElementById("PanelUI-menu-button"); + + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + is( + menuButton.hasAttribute("badge"), + false, + "Should not have the badge attribute set" + ); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + is( + menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Should have fxa-needs-authentication badge status" + ); + + AppMenuNotifications.showBadgeOnlyNotification("update-succeeded"); + is( + menuButton.getAttribute("badge-status"), + "update-succeeded", + "Should have update-succeeded badge status (update > fxa)" + ); + + AppMenuNotifications.showBadgeOnlyNotification("update-failed"); + is( + menuButton.getAttribute("badge-status"), + "update-failed", + "Should have update-failed badge status" + ); + + AppMenuNotifications.removeNotification(/^update-/); + is( + menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Should have fxa-needs-authentication badge status" + ); + + AppMenuNotifications.removeNotification(/^fxa-/); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + + await gCUITestUtils.openMainMenu(); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status (Hamburger menu opened)" + ); + await gCUITestUtils.hideMainMenu(); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + AppMenuNotifications.showBadgeOnlyNotification("update-succeeded"); + AppMenuNotifications.removeNotification(/.*/); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that non-badges also operate like a stack. + */ +add_task(async function testMultipleNonBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let updateManualAction = { + called: false, + callback: () => { + updateManualAction.called = true; + }, + }; + let updateRestartAction = { + called: false, + callback: () => { + updateRestartAction.called = true; + }, + }; + + AppMenuNotifications.showNotification("update-manual", updateManualAction); + + let notifications; + let doorhanger; + + isnot(PanelUI.notificationPanel.state, "closed", "Doorhanger is showing."); + notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + AppMenuNotifications.showNotification( + "update-restart", + updateRestartAction + ); + + isnot(PanelUI.notificationPanel.state, "closed", "Doorhanger is showing."); + notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-restart-notification", + "PanelUI is displaying the update-restart notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-restart", + "update-restart badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-restart", + "update-restart badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-restart", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-restart menu item is showing."); + + menuItem.click(); + ok( + updateRestartAction.called, + "update-restart main action callback was called" + ); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "update-manual badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "update-manual badge is displaying on PanelUI button." + ); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + menuItem.click(); + ok( + updateManualAction.called, + "update-manual main action callback was called" + ); + }); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js new file mode 100644 index 0000000000..df856dd4cf --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * The update banner should become visible when the badge-only notification is + * shown before opening the menu. + */ +add_task(async function testBannerVisibilityBeforeOpen() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + AppMenuNotifications.showBadgeOnlyNotification("update-restart"); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * The update banner should become visible when the badge-only notification is + * shown during the menu is opened. + */ +add_task(async function testBannerVisibilityDuringOpen() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + ok( + !banner.hasAttribute("label"), + "Update banner shouldn't contain text before notification" + ); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + AppMenuNotifications.showNotification("update-restart"); + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * The update banner should become visible when the badge-only notification is + * shown after opening/closing the menu, so that the DOM tree is there but + * the menu is closed. + */ +add_task(async function testBannerVisibilityAfterClose() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + ok(newWin.PanelUI.mainView.hasAttribute("visible")); + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + + ok(banner.hidden, "Update banner should be hidden before notification"); + ok( + !banner.hasAttribute("label"), + "Update banner shouldn't contain text before notification" + ); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + let hidden = BrowserTestUtils.waitForCondition(() => { + return !newWin.PanelUI.mainView.hasAttribute("visible"); + }); + menuButton.click(); + await hidden; + + AppMenuNotifications.showBadgeOnlyNotification("update-restart"); + + shown = BrowserTestUtils.waitForEvent(newWin.PanelUI.mainView, "ViewShown"); + menuButton.click(); + await shown; + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js new file mode 100644 index 0000000000..4b3340696b --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js @@ -0,0 +1,92 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +add_task(async function testFullscreen() { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + document.documentElement.focus(); + EventUtils.synthesizeKey("KEY_F11"); + await popuphiddenPromise; + await new Promise(executeSoon); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + FullScreen.showNavToolbox(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + EventUtils.synthesizeKey("KEY_F11"); + await popupshownPromise; + await new Promise(executeSoon); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is not displaying on PanelUI button." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js new file mode 100644 index 0000000000..853c39e89f --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js @@ -0,0 +1,145 @@ +"use strict"; + +// This test tends to trigger a race in the fullscreen time telemetry, +// where the fullscreen enter and fullscreen exit events (which use the +// same histogram ID) overlap. That causes TelemetryStopwatch to log an +// error. +SimpleTest.ignoreAllUncaughtExceptions(true); + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +function waitForDocshellActivated() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + // Setting docshell activated/deactivated will trigger visibility state + // changes to relevant state ("visible" or "hidden"). AFAIK, there is no + // such event notifying docshell is being activated, so I use + // "visibilitychange" event rather than polling the isActive flag. + await ContentTaskUtils.waitForEvent( + content.document, + "visibilitychange", + true /* capture */, + aEvent => { + return content.browsingContext.isActive; + } + ); + }); +} + +function waitForFullscreen() { + return Promise.all([ + BrowserTestUtils.waitForEvent(window, "fullscreen"), + // In the platforms that support reporting occlusion state (e.g. Mac), + // enter/exit fullscreen mode will trigger docshell being set to non-activate + // and then set to activate back again. For those platforms, we should wait + // until the docshell has been activated again before starting next test, + // otherwise, the fullscreen request might be denied. + Services.appinfo.OS === "Darwin" + ? waitForDocshellActivated() + : Promise.resolve(), + ]); +} + +add_task(async function testFullscreen() { + if (Services.appinfo.OS !== "Darwin") { + await SpecialPowers.pushPrefEnv({ + set: [["browser.fullscreen.autohide", false]], + }); + } + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + await BrowserTestUtils.waitForEvent(PanelUI.notificationPanel, "popupshown"); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let fullscreenPromise = waitForFullscreen(); + EventUtils.synthesizeKey("KEY_F11"); + await fullscreenPromise; + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is still showing after entering fullscreen." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.document.documentElement.requestFullscreen(); + }); + await popuphiddenPromise; + await new Promise(executeSoon); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is hidden after entering DOM fullscreen." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.document.exitFullscreen(); + }); + await popupshownPromise; + await new Promise(executeSoon); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is shown after exiting DOM fullscreen." + ); + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is not displaying on PanelUI button." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + + fullscreenPromise = BrowserTestUtils.waitForEvent(window, "fullscreen"); + EventUtils.synthesizeKey("KEY_F11"); + await fullscreenPromise; +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_modals.js b/browser/components/customizableui/test/browser_panelUINotifications_modals.js new file mode 100644 index 0000000000..87be14fcee --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_modals.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +add_task(async function testModals() { + await SpecialPowers.pushPrefEnv({ + set: [["prompts.windowPromptSubDialog", true]], + }); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); + Services.prompt.asyncAlert( + window.browsingContext, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + "Test alert", + "Test alert description" + ); + await popuphiddenPromise; + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + + await dialogPromise; + await popupshownPromise; + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js new file mode 100644 index 0000000000..fd75763857 --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js @@ -0,0 +1,214 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * Tests that when we try to show a notification in a background window, it + * does not display until the window comes back into the foreground. However, + * it should display a badge. + */ +add_task(async function testDoesNotShowDoorhangerForBackgroundWindow() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The background window has a badge." + ); + + let popupShown = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + await popupShown; + + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let button = doorhanger.button; + + Assert.equal( + PanelUI.notificationPanel.state, + "open", + "Expect panel state to be open when clicking panel buttons" + ); + button.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that when we try to show a notification in a background window and in + * a foreground window, if the foreground window's main action is called, the + * background window's doorhanger will be removed. + */ +add_task( + async function testBackgroundWindowNotificationsAreRemovedByForeground() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + doorhanger.button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); + } +); + +/** + * Tests that when we try to show a notification in a background window and in + * a foreground window, if the foreground window's doorhanger is dismissed, + * the background window's doorhanger will also be dismissed once the window + * regains focus. + */ +add_task( + async function testBackgroundWindowNotificationsAreDismissedByForeground() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + let button = doorhanger.secondaryButton; + button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The dismissed notification should still have a badge status" + ); + + AppMenuNotifications.removeNotification(/.*/); + }); + } +); + +/** + * Tests that when we open a new window while a notification is showing, the + * notification also shows on the new window. + */ +add_task(async function testOpenWindowAfterShowingNotification() { + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + let button = doorhanger.secondaryButton; + button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The dismissed notification should still have a badge status" + ); + + AppMenuNotifications.removeNotification(/.*/); +}); diff --git a/browser/components/customizableui/test/browser_panel_keyboard_navigation.js b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js new file mode 100644 index 0000000000..9b5ad48cce --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js @@ -0,0 +1,326 @@ +"use strict"; + +/** + * Test keyboard navigation in the app menu panel. + */ + +const kHelpButtonId = "appMenu-help-button2"; + +function getEnabledNavigableElementsForView(panelView) { + return Array.from( + panelView.querySelectorAll("button,toolbarbutton,menulist,.text-link") + ).filter(element => { + let bounds = element.getBoundingClientRect(); + return !element.disabled && bounds.width > 0 && bounds.height > 0; + }); +} + +add_task(async function testUpDownKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + for (let button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after navigating downward" + ); + } + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[0], + "Pressing upwards should cycle around and select the first button again" + ); + + for (let i = buttons.length - 1; i >= 0; --i) { + let button = buttons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The first button should be focused after navigating upward" + ); + } + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testHomeEndKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let enabledButtons = buttons.filter(btn => !btn.disabled); + let firstButton = enabledButtons[0]; + let lastButton = enabledButtons.pop(); + + Assert.ok(firstButton != lastButton, "There is more than one button"); + + EventUtils.synthesizeKey("KEY_End"); + Assert.equal( + document.commandDispatcher.focusedElement, + lastButton, + "The last button should be focused after pressing End" + ); + + EventUtils.synthesizeKey("KEY_Home"); + Assert.equal( + document.commandDispatcher.focusedElement, + firstButton, + "The first button should be focused after pressing Home" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testEnterKeyBehaviors() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + // Navigate to the 'Help' button, which points to a subview. + EventUtils.synthesizeKey("KEY_ArrowUp"); + let focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + buttons[buttons.length - 1], + "The last button should be focused after navigating upward" + ); + + // Make sure the Help button is in focus. + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kHelpButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + EventUtils.synthesizeKey("KEY_Enter"); + + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + let helpButtons = getEnabledNavigableElementsForView(helpView); + Assert.ok( + helpButtons[0].classList.contains("subviewbutton-back"), + "First button in help view should be a back button" + ); + + // For posterity, check navigating the subview using up/ down arrow keys as well. + // When opening a subview, the first control *after* the Back button gets + // focus. + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + helpButtons[0], + "The Back button should be focused after navigating upward" + ); + for (let i = helpButtons.length - 1; i >= 0; --i) { + let button = helpButtons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + button, + "The previous button should be focused after navigating upward" + ); + } + + // Make sure the back button is in focus again. + while (focusedElement != helpButtons[0]) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + focusedElement = document.commandDispatcher.focusedElement; + } + + // The first button is the back button. Hittin Enter should navigate us back. + let promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_Enter"); + await promise; + + // Let's test a 'normal' command button. + focusedElement = document.commandDispatcher.focusedElement; + const kFindButtonId = "appMenu-find-button2"; + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kFindButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + let findBarPromise = gBrowser.isFindBarInitialized() + ? null + : BrowserTestUtils.waitForEvent(gBrowser.selectedTab, "TabFindInitialized"); + Assert.equal( + focusedElement.id, + kFindButtonId, + "Find button should be selected" + ); + + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + await findBarPromise; + Assert.ok(!gFindBar.hidden, "Findbar should have opened"); + gFindBar.close(); +}); + +add_task(async function testLeftRightKeys() { + await gCUITestUtils.openMainMenu(); + + // Navigate to the 'Help' button, which points to a subview. + let focusedElement = document.commandDispatcher.focusedElement; + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kHelpButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + Assert.equal( + focusedElement.id, + kHelpButtonId, + "The last button should be focused after navigating upward" + ); + + // Hitting ArrowRight on a button that points to a subview should navigate us + // there. + EventUtils.synthesizeKey("KEY_ArrowRight"); + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + // Hitting ArrowLeft should navigate us back. + let promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await promise; + + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement.id, + kHelpButtonId, + "Help button should be focused again now that we're back in the main view" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testTabKey() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + for (let button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after tabbing" + ); + } + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[0], + "Pressing tab should cycle around and select the first button again" + ); + + for (let i = buttons.length - 1; i >= 0; --i) { + let button = buttons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after shift + tabbing" + ); + } + + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[buttons.length - 1], + "Pressing shift + tab should cycle around and select the last button again" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testInterleavedTabAndArrowKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let tab = false; + + for (let button of buttons) { + if (button.disabled) { + continue; + } + if (tab) { + EventUtils.synthesizeKey("KEY_Tab"); + } else { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + tab = !tab; + } + + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[buttons.length - 1], + "The last button should be focused after a mix of Tab and ArrowDown" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testSpaceDownAfterTabNavigation() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let button; + + for (button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab"); + if (button.id == kHelpButtonId) { + break; + } + } + + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "Help button should be focused after tabbing to it." + ); + + // Pressing down space on a button that points to a subview should navigate us + // there, before keyup. + EventUtils.synthesizeKey(" ", { type: "keydown" }); + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/customizableui/test/browser_panel_locationSpecific.js b/browser/components/customizableui/test/browser_panel_locationSpecific.js new file mode 100644 index 0000000000..9965a141b2 --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_locationSpecific.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test creates multiple panels, one that has been tagged as location specific + * and one that isn't. When the location changes, the specific panel should close. + * The non-specific panel should remain open. + * + */ + +add_task(async function () { + let specificPanel = document.createXULElement("panel"); + specificPanel.setAttribute("locationspecific", "true"); + specificPanel.setAttribute("noautohide", "true"); + specificPanel.style.height = "100px"; + specificPanel.style.width = "100px"; + + let generalPanel = document.createXULElement("panel"); + generalPanel.setAttribute("noautohide", "true"); + generalPanel.style.height = "100px"; + generalPanel.style.width = "100px"; + + let anchor = document.getElementById(CustomizableUI.AREA_NAVBAR); + + anchor.appendChild(specificPanel); + anchor.appendChild(generalPanel); + is(specificPanel.state, "closed", "specificPanel starts as closed"); + is(generalPanel.state, "closed", "generalPanel starts as closed"); + + let specificPanelPromise = BrowserTestUtils.waitForEvent( + specificPanel, + "popupshown" + ); + + specificPanel.openPopupAtScreen(0, 0); + + await specificPanelPromise; + is(specificPanel.state, "open", "specificPanel has been opened"); + + let generalPanelPromise = BrowserTestUtils.waitForEvent( + generalPanel, + "popupshown" + ); + + generalPanel.openPopupAtScreen(100, 0); + + await generalPanelPromise; + is(generalPanel.state, "open", "generalPanel has been opened"); + + let specificPanelHiddenPromise = BrowserTestUtils.waitForEvent( + specificPanel, + "popuphidden" + ); + + // Simulate a location change, and check which panel closes. + let browser = gBrowser.selectedBrowser; + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, "http://mochi.test:8888/#0"); + await loaded; + + await specificPanelHiddenPromise; + + is( + specificPanel.state, + "closed", + "specificPanel panel is closed after location change" + ); + is( + generalPanel.state, + "open", + "generalPanel is still open after location change" + ); + + specificPanel.remove(); + generalPanel.remove(); +}); diff --git a/browser/components/customizableui/test/browser_panel_menulist.js b/browser/components/customizableui/test/browser_panel_menulist.js new file mode 100644 index 0000000000..c863a872ee --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_menulist.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kViewID = "panelview-with-menulist"; + +/** + * When there's a menulist inside a panelview, closing it shouldn't close the panel. + */ +add_task(async function test_closing_menulist_should_not_close_panel() { + let viewCache = document.getElementById("appMenu-viewCache"); + let panelview = document.createXULElement("panelview"); + panelview.id = kViewID; + let menulist = document.createXULElement("menulist"); + let popup = document.createXULElement("menupopup"); + for (let item of ["one", "two"]) { + let menuitem = document.createXULElement("menuitem"); + menuitem.id = `menuitem-${item}`; + menuitem.setAttribute("label", item); + popup.append(menuitem); + } + menulist.append(popup); + panelview.append(menulist); + viewCache.append(panelview); + await PanelUI.showSubView(kViewID, PanelUI.menuButton); + let panel = panelview.closest("panel"); + + // Ensure that not only has the subview started showing, the panel is + // all the way open: + await BrowserTestUtils.waitForPopupEvent(panel, "shown"); + + registerCleanupFunction(async () => { + if (panel && panel.state != "closed") { + let panelGone = BrowserTestUtils.waitForPopupEvent(panel, "hidden"); + panel.hidePopup(); + await panelGone; + } + panelview.remove(); + }); + + let shown = BrowserTestUtils.waitForPopupEvent(popup, "shown"); + menulist.openMenu(true); + await shown; + let hidden = BrowserTestUtils.waitForPopupEvent(popup, "hidden"); + popup.activateItem(popup.firstElementChild); + await hidden; + + Assert.equal(panel?.state, "open", "Panel should still be open."); +}); diff --git a/browser/components/customizableui/test/browser_panel_toggle.js b/browser/components/customizableui/test/browser_panel_toggle.js new file mode 100644 index 0000000000..cba441e8e5 --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_toggle.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Test opening and closing the menu panel UI. + */ + +// Show and hide the menu panel programmatically without an event (like UITour.sys.mjs would) +add_task(async function () { + await gCUITestUtils.openMainMenu(); + + is( + PanelUI.panel.getAttribute("panelopen"), + "true", + "Check that panel has panelopen attribute" + ); + is(PanelUI.panel.state, "open", "Check that panel state is 'open'"); + + await gCUITestUtils.hideMainMenu(); + + ok( + !PanelUI.panel.hasAttribute("panelopen"), + "Check that panel doesn't have the panelopen attribute" + ); + is(PanelUI.panel.state, "closed", "Check that panel state is 'closed'"); +}); + +// Toggle the menu panel open and closed +add_task(async function () { + await gCUITestUtils.openPanelMultiView(PanelUI.panel, PanelUI.mainView, () => + PanelUI.toggle({ type: "command" }) + ); + + is( + PanelUI.panel.getAttribute("panelopen"), + "true", + "Check that panel has panelopen attribute" + ); + is(PanelUI.panel.state, "open", "Check that panel state is 'open'"); + + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, () => + PanelUI.toggle({ type: "command" }) + ); + + ok( + !PanelUI.panel.hasAttribute("panelopen"), + "Check that panel doesn't have the panelopen attribute" + ); + is(PanelUI.panel.state, "closed", "Check that panel state is 'closed'"); +}); diff --git a/browser/components/customizableui/test/browser_proton_moreTools_panel.js b/browser/components/customizableui/test/browser_proton_moreTools_panel.js new file mode 100644 index 0000000000..8104b8920e --- /dev/null +++ b/browser/components/customizableui/test/browser_proton_moreTools_panel.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineLazyGetter(this, "DevToolsStartup", () => { + return Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsICommandLineHandler + ).wrappedJSObject; +}); + +// Test activating the developer button shows the More Tools panel. +add_task(async function testDevToolsPanelInToolbar() { + // We need to force DevToolsStartup to rebuild the developer tool toggle so that + // proton prefs are applied to the new browser window for this test. + DevToolsStartup.developerToggleCreated = false; + CustomizableUI.destroyWidget("developer-button"); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + CustomizableUI.addWidgetToArea( + "developer-button", + CustomizableUI.AREA_NAVBAR + ); + + // Test the developer tools panel is showing. + let button = document.getElementById("developer-button"); + let devToolsView = PanelMultiView.getViewNode( + document, + "PanelUI-developer-tools" + ); + let devToolsShownPromise = BrowserTestUtils.waitForEvent( + devToolsView, + "ViewShown" + ); + + EventUtils.synthesizeMouseAtCenter(button, {}); + await devToolsShownPromise; + ok(true, "Dev Tools view is showing"); + is( + devToolsView.children.length, + 1, + "Dev tools subview is the only child of panel" + ); + is( + devToolsView.children[0].id, + "PanelUI-developer-tools-view", + "Dev tools child has correct id" + ); + + // Cleanup + await BrowserTestUtils.closeWindow(win); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js b/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js new file mode 100644 index 0000000000..ac46fd12ae --- /dev/null +++ b/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js @@ -0,0 +1,285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + HomePage: "resource:///modules/HomePage.sys.mjs", +}); + +const kPrefProtonToolbarVersion = "browser.proton.toolbar.version"; +const kPrefHomeButtonUsed = "browser.engagement.home-button.has-used"; +const kPrefLibraryButtonUsed = "browser.engagement.library-button.has-used"; +const kPrefSidebarButtonUsed = "browser.engagement.sidebar-button.has-used"; + +async function testToolbarButtons(aActions) { + let { + shouldRemoveHomeButton, + shouldRemoveLibraryButton, + shouldRemoveSidebarButton, + shouldUpdateVersion, + } = aActions; + const defaultPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + "home-button", + "customizableui-special-spring1", + "urlbar-container", + "customizableui-special-spring2", + "downloads-button", + "library-button", + "sidebar-button", + "fxa-toolbar-menu-button", + ]; + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + placements: { + "nav-bar": defaultPlacements, + }, + }); + CustomizableUIInternal._updateForNewProtonVersion(); + + let navbarPlacements = + CustomizableUI.getTestOnlyInternalProp("gSavedState").placements["nav-bar"]; + let includesHomeButton = navbarPlacements.includes("home-button"); + let includesLibraryButton = navbarPlacements.includes("library-button"); + let includesSidebarButton = navbarPlacements.includes("sidebar-button"); + + Assert.equal( + !includesHomeButton, + shouldRemoveHomeButton, + "Correctly handles home button" + ); + Assert.equal( + !includesLibraryButton, + shouldRemoveLibraryButton, + "Correctly handles library button" + ); + Assert.equal( + !includesSidebarButton, + shouldRemoveSidebarButton, + "Correctly handles sidebar button" + ); + + let toolbarVersion = Services.prefs.getIntPref(kPrefProtonToolbarVersion); + if (shouldUpdateVersion) { + Assert.ok(toolbarVersion >= 1, "Toolbar proton version updated"); + } else { + Assert.ok(toolbarVersion == 0, "Toolbar proton version not updated"); + } + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); +} + +/** + * Checks that the home button is removed from the nav-bar under + * these conditions: proton must be enabled, the toolbar engagement + * pref is false, and the homepage is about:home or about:blank. + * Otherwise, the home button should remain if it was previously + * in the navbar. + * Also checks that the library button is removed from the nav-bar + * if proton is enabled and the toolbar engagement pref is false. + */ +add_task(async function testButtonRemoval() { + // Ensure the engagement prefs are set to their default values + await SpecialPowers.pushPrefEnv({ + set: [ + [kPrefHomeButtonUsed, false], + [kPrefLibraryButtonUsed, false], + [kPrefSidebarButtonUsed, false], + ], + }); + + let tests = [ + // Proton enabled without home and library engagement + { + prefs: [], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with home engagement + { + prefs: [[kPrefHomeButtonUsed, true]], + actions: { + shouldRemoveHomeButton: false, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with custom homepage + { + prefs: [], + actions: { + shouldRemoveHomeButton: false, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + async fn() { + HomePage.safeSet("https://example.com"); + }, + }, + // Proton enabled with library engagement + { + prefs: [[kPrefLibraryButtonUsed, true]], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: false, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with sidebar engagement + { + prefs: [[kPrefSidebarButtonUsed, true]], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: false, + shouldUpdateVersion: true, + }, + }, + ]; + + for (let test of tests) { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0], ...test.prefs], + }); + if (test.fn) { + await test.fn(); + } + testToolbarButtons(test.actions); + HomePage.reset(); + await SpecialPowers.popPrefEnv(); + } +}); + +/** + * Checks that a null saved state (new profile) does not prevent migration. + */ +add_task(async function testNullSavedState() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + CustomizableUIInternal.initialize(); + + Assert.ok( + Services.prefs.getIntPref(kPrefProtonToolbarVersion) >= 1, + "Toolbar proton version updated" + ); + let navbarPlacements = CustomizableUI.getTestOnlyInternalProp("gAreas") + .get("nav-bar") + .get("defaultPlacements"); + Assert.ok( + !navbarPlacements.includes("home-button"), + "Home button isn't included by default" + ); + Assert.ok( + !navbarPlacements.includes("library-button"), + "Library button isn't included by default" + ); + Assert.ok( + !navbarPlacements.includes("sidebar-button"), + "Sidebar button isn't included by default" + ); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + await SpecialPowers.popPrefEnv(); + // Re-initialize to prevent future test failures + CustomizableUIInternal.initialize(); +}); + +/** + * Checks that a saved state that is missing nav-bar placements does not prevent migration. + */ +add_task(async function testNoNavbarPlacements() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + placements: { "widget-overflow-fixed-list": [] }, + }); + CustomizableUIInternal._updateForNewProtonVersion(); + + Assert.ok(true, "_updateForNewProtonVersion didn't throw"); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Checks that a saved state that is missing the placements value does not prevent migration. + */ +add_task(async function testNullPlacements() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", {}); + CustomizableUIInternal._updateForNewProtonVersion(); + + Assert.ok(true, "_updateForNewProtonVersion didn't throw"); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/customizableui/test/browser_registerArea.js b/browser/components/customizableui/test/browser_registerArea.js new file mode 100644 index 0000000000..2900c9eb8b --- /dev/null +++ b/browser/components/customizableui/test/browser_registerArea.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that a toolbar area can be registered with overflowable: false + * as one of its properties, and this results in a non-overflowable + * toolbar. + */ +add_task(async function test_overflowable_false() { + registerCleanupFunction(removeCustomToolbars); + + const kToolbarId = "no-overflow-toolbar"; + createToolbarWithPlacements(kToolbarId, ["spring"], { + overflowable: false, + }); + + let node = CustomizableUI.getWidget(kToolbarId).forWindow(window).node; + Assert.ok( + !node.hasAttribute("overflowable"), + "Toolbar should not be overflowable" + ); + Assert.ok( + !node.overflowable, + "OverflowableToolbar instance should not have been created." + ); +}); diff --git a/browser/components/customizableui/test/browser_reload_tab.js b/browser/components/customizableui/test/browser_reload_tab.js new file mode 100644 index 0000000000..ba44ba1e34 --- /dev/null +++ b/browser/components/customizableui/test/browser_reload_tab.js @@ -0,0 +1,103 @@ +"use strict"; + +/** + * Check that customize mode doesn't break when its tab is reloaded. + */ +add_task(async function reload_tab() { + let initialTab = gBrowser.selectedTab; + let customizeTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + gCustomizeMode.setTab(customizeTab); + let customizationContainer = document.getElementById( + "customization-container" + ); + + is( + customizationContainer.clientWidth, + 0, + "Customization container shouldn't be visible (X)" + ); + is( + customizationContainer.clientHeight, + 0, + "Customization container shouldn't be visible (Y)" + ); + + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizePromise; + + let tabReloaded = new Promise(resolve => { + gBrowser.addTabsProgressListener({ + async onLocationChange(aBrowser) { + if (customizeTab.linkedBrowser == aBrowser) { + gBrowser.removeTabsProgressListener(this); + await Promise.resolve(); + resolve(); + } + }, + }); + }); + gBrowser.reloadTab(customizeTab); + await tabReloaded; + + is( + gBrowser.getIcon(customizeTab), + "chrome://browser/skin/customize.svg", + "Tab should have customize icon" + ); + is( + customizeTab.getAttribute("customizemode"), + "true", + "Tab should be in customize mode" + ); + Assert.greater( + customizationContainer.clientWidth, + 0, + "Customization container should be visible (X)" + ); + Assert.greater( + customizationContainer.clientHeight, + 0, + "Customization container should be visible (Y)" + ); + + customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, initialTab); + await customizePromise; + + customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + await BrowserTestUtils.switchTab(gBrowser, customizeTab); + await customizePromise; + + is( + gBrowser.getIcon(customizeTab), + "chrome://browser/skin/customize.svg", + "Tab should still have customize icon" + ); + is( + customizeTab.getAttribute("customizemode"), + "true", + "Tab should still be in customize mode" + ); + Assert.greater( + customizationContainer.clientWidth, + 0, + "Customization container should still be visible (X)" + ); + Assert.greater( + customizationContainer.clientHeight, + 0, + "Customization container should still be visible (Y)" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_remote_attribute.js b/browser/components/customizableui/test/browser_remote_attribute.js new file mode 100644 index 0000000000..543e62e2bc --- /dev/null +++ b/browser/components/customizableui/test/browser_remote_attribute.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * These tests check that the remote attribute is true for remote panels. + * This attribute is needed for Mac to properly render the panel. + */ +add_task(async function check_remote_attribute() { + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + //let pocketPanelShown = BrowserTestUtils.waitForEvent( + // document, + // "popupshown", + // true + //); + let pocketPanelShown = popupShown(document); + // Using Pocket panel as it's an available remote panel. + let pocketButton = document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await pocketPanelShown; + + let pocketPanel = document.getElementById("customizationui-widget-panel"); + is( + pocketPanel.getAttribute("remote"), + "true", + "Pocket panel has remote attribute" + ); + + // Close panel and cleanup. + let pocketPanelHidden = popupHidden(pocketPanel); + pocketPanel.hidePopup(); + await pocketPanelHidden; +}); + +add_task(async function check_remote_attribute_overflow() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let overflowPanel = win.document.getElementById("widget-overflow"); + overflowPanel.setAttribute("animate", "false"); + + // Force a narrow window to get an overflow toolbar. + win.resizeTo(kForceOverflowWidthPx, win.outerHeight); + let navbar = win.document.getElementById(CustomizableUI.AREA_NAVBAR); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + + // Open the overflow panel view. + let overflowPanelShown = popupShown(overflowPanel); + let overflowPanelButton = win.document.getElementById( + "nav-bar-overflow-button" + ); + overflowPanelButton.click(); + await overflowPanelShown; + + // Using Pocket panel as it's an available remote panel. + let pocketButton = win.document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await BrowserTestUtils.waitForEvent(win.document, "ViewShown"); + + is( + overflowPanel.getAttribute("remote"), + "true", + "Pocket overflow panel has remote attribute" + ); + + // Close panel and cleanup. + let overflowPanelHidden = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await overflowPanelHidden; + overflowPanel.removeAttribute("animate"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_remote_tabs_button.js b/browser/components/customizableui/test/browser_remote_tabs_button.js new file mode 100644 index 0000000000..094335d4b1 --- /dev/null +++ b/browser/components/customizableui/test/browser_remote_tabs_button.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +"use strict"; + +let { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +let getState; +let originalSync; +let syncWasCalled = false; + +// TODO: This test should probably be re-written, we don't really test much here. +add_task(async function testSyncRemoteTabsButtonFunctionality() { + info("Test the Sync Remote Tabs button in the panel"); + storeInitialValues(); + mockFunctions(); + + // Force UI update. + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + // add the sync remote tabs button to the panel + CustomizableUI.addWidgetToArea( + "sync-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality + await document.getElementById("nav-bar").overflowable.show(); + info("The panel menu was opened"); + + let syncRemoteTabsBtn = document.getElementById("sync-button"); + ok( + syncRemoteTabsBtn, + "The sync remote tabs button was added to the Panel Menu" + ); + // click the button - the panel should open. + syncRemoteTabsBtn.click(); + let remoteTabsPanel = document.getElementById("PanelUI-remotetabs"); + let viewShown = BrowserTestUtils.waitForEvent(remoteTabsPanel, "ViewShown"); + await viewShown; + ok(remoteTabsPanel.getAttribute("visible"), "Sync Panel is in view"); + + // Find and click the "setup" button. + let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); + syncNowButton.click(); + info("The sync now button was clicked"); + + await TestUtils.waitForCondition(() => syncWasCalled); + + // We need to stop the Syncing animation manually otherwise the button + // will be disabled at the beginning of a next test. + gSync._onActivityStop(); +}); + +add_task(async function asyncCleanup() { + // reset the panel UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); + + if (isOverflowOpen()) { + let panelHidePromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHidePromise; + } + + restoreValues(); +}); + +function mockFunctions() { + // mock UIState.get() + UIState.get = () => ({ + status: UIState.STATUS_SIGNED_IN, + lastSync: new Date(), + email: "user@mozilla.com", + }); + + Service.sync = mocked_sync; +} + +function mocked_sync() { + syncWasCalled = true; +} + +function restoreValues() { + UIState.get = getState; + Service.sync = originalSync; +} + +function storeInitialValues() { + getState = UIState.get; + originalSync = Service.sync; +} diff --git a/browser/components/customizableui/test/browser_remove_customized_specials.js b/browser/components/customizableui/test/browser_remove_customized_specials.js new file mode 100644 index 0000000000..1f123d10cb --- /dev/null +++ b/browser/components/customizableui/test/browser_remove_customized_specials.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that after a reset, we can still drag special nodes in customize mode + */ +add_task(async function () { + await startCustomizing(); + CustomizableUI.addWidgetToArea("spring", "nav-bar", 5); + await gCustomizeMode.reset(); + let springs = document.querySelectorAll("#nav-bar toolbarspring"); + let lastSpring = springs[springs.length - 1]; + let expectedPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + info("Placements before drag: " + expectedPlacements.join(",")); + let lastItem = document.getElementById( + expectedPlacements[expectedPlacements.length - 1] + ); + await waitForElementShown(lastItem); + simulateItemDrag(lastSpring, lastItem, "end"); + expectedPlacements.splice(expectedPlacements.indexOf(lastSpring.id), 1); + expectedPlacements.push(lastSpring.id); + let actualPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + // Log these separately because Assert.deepEqual truncates the stringified versions... + info("Actual placements: " + actualPlacements.join(",")); + info("Expected placements: " + expectedPlacements.join(",")); + Assert.deepEqual( + expectedPlacements, + actualPlacements, + "Should be able to move spring" + ); + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js b/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js new file mode 100644 index 0000000000..fa9e497734 --- /dev/null +++ b/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that if we move a non-default, but builtin, widget to another area, +// and then reset things, the currentArea is updated correctly. +add_task(async function reset_should_not_keep_currentArea() { + CustomizableUI.addWidgetToArea( + "save-page-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + // We can't check currentArea directly; check areaType which is based on it: + is( + CustomizableUI.getWidget("save-page-button").areaType, + CustomizableUI.TYPE_PANEL, + "Button should know it's in the overflow panel" + ); + CustomizableUI.reset(); + ok( + !CustomizableUI.getWidget("save-page-button").areaType, + "Button should know it's not in the overflow panel anymore" + ); +}); + +registerCleanupFunction(() => CustomizableUI.reset()); diff --git a/browser/components/customizableui/test/browser_reset_dom_events.js b/browser/components/customizableui/test/browser_reset_dom_events.js new file mode 100644 index 0000000000..2922fe481d --- /dev/null +++ b/browser/components/customizableui/test/browser_reset_dom_events.js @@ -0,0 +1,34 @@ +"use strict"; + +const widgetId = "import-button"; +const listener = { + _beforeCount: 0, + _afterCount: 0, + onWidgetBeforeDOMChange(node) { + if (node.id == widgetId) { + this._beforeCount++; + } + }, + onWidgetAfterDOMChange(node) { + if (node.id == widgetId) { + this._afterCount++; + } + }, +}; + +add_task(async function test_reset_dom_events() { + await startCustomizing(); + + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_BOOKMARKS); + CustomizableUI.addListener(listener); + + info("Resetting"); + await gCustomizeMode.reset(); + + is(listener._beforeCount, 1, "Should've been notified of the mutation"); + is(listener._afterCount, 1, "Should've been notified of the mutation"); + + CustomizableUI.removeListener(listener); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_screenshot_button_disabled.js b/browser/components/customizableui/test/browser_screenshot_button_disabled.js new file mode 100644 index 0000000000..b8eca2b3d3 --- /dev/null +++ b/browser/components/customizableui/test/browser_screenshot_button_disabled.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +"use strict"; + +add_task(async function testScreenshotButtonPrefDisabled() { + info("Test the Screenshots widget not available"); + + Assert.ok( + Services.prefs.getBoolPref("extensions.screenshots.disabled"), + "Sceenshots feature is disabled" + ); + + CustomizableUI.addWidgetToArea( + "screenshot-button", + CustomizableUI.AREA_NAVBAR + ); + + let screenshotBtn = document.getElementById("screenshot-button"); + Assert.ok(!screenshotBtn, "Screenshot button is unavailable"); +}); diff --git a/browser/components/customizableui/test/browser_searchbar_removal.js b/browser/components/customizableui/test/browser_searchbar_removal.js new file mode 100644 index 0000000000..ac847c8a4c --- /dev/null +++ b/browser/components/customizableui/test/browser_searchbar_removal.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchWidgetTracker } = ChromeUtils.importESModule( + "resource:///modules/SearchWidgetTracker.sys.mjs" +); + +const SEARCH_BAR_PREF_NAME = "browser.search.widget.inNavBar"; +const SEARCH_BAR_LAST_USED_PREF_NAME = "browser.search.widget.lastUsed"; + +add_task(async function checkSearchBarPresent() { + Services.prefs.setBoolPref(SEARCH_BAR_PREF_NAME, true); + Services.prefs.setStringPref( + SEARCH_BAR_LAST_USED_PREF_NAME, + new Date("2022").toISOString() + ); + + Assert.ok( + BrowserSearch.searchBar, + "Search bar should be present in the Nav bar" + ); + SearchWidgetTracker._updateSearchBarVisibilityBasedOnUsage(); + Assert.ok( + !BrowserSearch.searchBar, + "Search bar should not be present in the Nav bar" + ); + Assert.equal( + Services.prefs.getBoolPref(SEARCH_BAR_PREF_NAME), + false, + "Should remove the search bar" + ); + Services.prefs.clearUserPref(SEARCH_BAR_LAST_USED_PREF_NAME); + Services.prefs.clearUserPref(SEARCH_BAR_PREF_NAME); +}); diff --git a/browser/components/customizableui/test/browser_sidebar_toggle.js b/browser/components/customizableui/test/browser_sidebar_toggle.js new file mode 100644 index 0000000000..5742f368ee --- /dev/null +++ b/browser/components/customizableui/test/browser_sidebar_toggle.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +registerCleanupFunction(async function () { + await resetCustomization(); + + // Ensure sidebar is hidden after each test: + if (!document.getElementById("sidebar-box").hidden) { + SidebarUI.hide(); + } +}); + +var showSidebar = async function (win = window) { + let button = win.document.getElementById("sidebar-button"); + let sidebarFocusedPromise = BrowserTestUtils.waitForEvent( + win.document, + "SidebarFocused" + ); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + await sidebarFocusedPromise; + ok(win.SidebarUI.isOpen, "Sidebar is opened"); + ok(button.hasAttribute("checked"), "Toolbar button is checked"); +}; + +var hideSidebar = async function (win = window) { + let button = win.document.getElementById("sidebar-button"); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + ok(!win.SidebarUI.isOpen, "Sidebar is closed"); + ok(!button.hasAttribute("checked"), "Toolbar button isn't checked"); +}; + +// Check the sidebar widget shows the default items +add_task(async function () { + CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar"); + + await showSidebar(); + is(SidebarUI.currentID, "viewBookmarksSidebar", "Default sidebar selected"); + await SidebarUI.show("viewHistorySidebar"); + + await hideSidebar(); + await showSidebar(); + is(SidebarUI.currentID, "viewHistorySidebar", "Selected sidebar remembered"); + + await hideSidebar(); + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + await showSidebar(otherWin); + is( + otherWin.SidebarUI.currentID, + "viewHistorySidebar", + "Selected sidebar remembered across windows" + ); + await hideSidebar(otherWin); + + await BrowserTestUtils.closeWindow(otherWin); +}); diff --git a/browser/components/customizableui/test/browser_switch_to_customize_mode.js b/browser/components/customizableui/test/browser_switch_to_customize_mode.js new file mode 100644 index 0000000000..55e80d3517 --- /dev/null +++ b/browser/components/customizableui/test/browser_switch_to_customize_mode.js @@ -0,0 +1,53 @@ +"use strict"; + +add_task(async function () { + await startCustomizing(); + is(gBrowser.tabs.length, 2, "Should have 2 tabs"); + + let paletteKidCount = document.getElementById( + "customization-palette" + ).childElementCount; + let nonCustomizingTab = gBrowser.tabContainer.querySelector( + "tab:not([customizemode=true])" + ); + let finishedCustomizing = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, nonCustomizingTab); + await finishedCustomizing; + + let startedCount = 0; + let handler = e => startedCount++; + gNavToolbox.addEventListener("customizationstarting", handler); + await startCustomizing(); + CustomizableUI.removeWidgetFromArea("stop-reload-button"); + await gCustomizeMode.reset().catch(e => { + ok( + false, + "Threw an exception trying to reset after making modifications in customize mode: " + + e + ); + }); + + let newKidCount = document.getElementById( + "customization-palette" + ).childElementCount; + is( + newKidCount, + paletteKidCount, + "Should have just as many items in the palette as before." + ); + await endCustomizing(); + is(startedCount, 1, "Should have only started once"); + gNavToolbox.removeEventListener("customizationstarting", handler); + let customizableToolbars = document.querySelectorAll( + "toolbar[customizable=true]:not([autohide=true])" + ); + for (let toolbar of customizableToolbars) { + ok( + !toolbar.hasAttribute("customizing"), + "Toolbar " + toolbar.id + " is no longer customizing" + ); + } +}); diff --git a/browser/components/customizableui/test/browser_synced_tabs_menu.js b/browser/components/customizableui/test/browser_synced_tabs_menu.js new file mode 100644 index 0000000000..ff60167fea --- /dev/null +++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js @@ -0,0 +1,523 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +requestLongerTimeout(2); + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +let { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +let { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + UITour: "resource:///modules/UITour.sys.mjs", +}); + +const DECKINDEX_TABS = 0; +const DECKINDEX_FETCHING = 1; +const DECKINDEX_TABSDISABLED = 2; +const DECKINDEX_NOCLIENTS = 3; + +const SAMPLE_TAB_URL = "https://example.com/"; + +var initialLocation = gBrowser.currentURI.spec; +var newTab = null; + +// A helper to notify there are new tabs. Returns a promise that is resolved +// once the UI has been updated. +function updateTabsPanel() { + let promiseTabsUpdated = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED); + return promiseTabsUpdated; +} + +// This is the mock we use for SyncedTabs.jsm - tests may override various +// functions. +let mockedInternal = { + get isConfiguredToSyncTabs() { + return true; + }, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + hasSyncedThisSession: false, +}; + +add_setup(async function () { + const getSignedInUser = FxAccounts.config.getSignedInUser; + FxAccounts.config.getSignedInUser = async () => + Promise.resolve({ uid: "uid", email: "foo@bar.com" }); + Services.prefs.setCharPref( + "identity.fxaccounts.remote.root", + "https://example.com/" + ); + + let oldInternal = SyncedTabs._internal; + SyncedTabs._internal = mockedInternal; + + let origNotifyStateUpdated = UIState._internal.notifyStateUpdated; + // Sync start-up will interfere with our tests, don't let UIState send UI updates. + UIState._internal.notifyStateUpdated = () => {}; + + // Force gSync initialization + gSync.init(); + + registerCleanupFunction(() => { + FxAccounts.config.getSignedInUser = getSignedInUser; + Services.prefs.clearUserPref("identity.fxaccounts.remote.root"); + UIState._internal.notifyStateUpdated = origNotifyStateUpdated; + SyncedTabs._internal = oldInternal; + }); +}); + +// The test expects the about:preferences#sync page to open in the current tab +async function openPrefsFromMenuPanel(expectedPanelId, entryPoint) { + info("Check Sync button functionality"); + CustomizableUI.addWidgetToArea( + "sync-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality + await document.getElementById("nav-bar").overflowable.show(); + + if (entryPoint == "uitour") { + UITour.tourBrowsersByWindow.set(window, new Set()); + UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser); + } + + let syncButton = document.getElementById("sync-button"); + ok(syncButton, "The Sync button was added to the Panel Menu"); + + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + syncButton.click(); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + + // Sync is not configured - verify that state is reflected. + let subpanel = document.getElementById(expectedPanelId); + ok(!subpanel.hidden, "sync setup element is visible"); + + // Find and click the "setup" button. + let setupButton = subpanel.querySelector(".PanelUI-remotetabs-button"); + setupButton.click(); + + await new Promise(resolve => { + let handler = async e => { + if ( + e.originalTarget != gBrowser.selectedBrowser.contentDocument || + e.target.location.href == "about:blank" + ) { + info("Skipping spurious 'load' event for " + e.target.location.href); + return; + } + gBrowser.selectedBrowser.removeEventListener("load", handler, true); + resolve(); + }; + gBrowser.selectedBrowser.addEventListener("load", handler, true); + }); + newTab = gBrowser.selectedTab; + + is( + gBrowser.currentURI.spec, + "about:preferences?entrypoint=" + entryPoint + "#sync", + "Firefox Sync preference page opened with `menupanel` entrypoint" + ); + ok(!isOverflowOpen(), "The panel closed"); + + if (isOverflowOpen()) { + await hideOverflow(); + } +} + +function hideOverflow() { + let panelHidePromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + return panelHidePromise; +} + +async function asyncCleanup() { + // reset the panel UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); + + // restore the tabs + BrowserTestUtils.addTab(gBrowser, initialLocation); + gBrowser.removeTab(newTab); + UITour.tourBrowsersByWindow.delete(window); +} + +// When Sync is not setup. +add_task(async function () { + gSync.updateAllUI({ status: UIState.STATUS_NOT_CONFIGURED }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs"); +}); +add_task(asyncCleanup); + +// When an account is connected by Sync is not enabled. +add_task(async function () { + gSync.updateAllUI({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false }); + await openPrefsFromMenuPanel( + "PanelUI-remotetabs-syncdisabled", + "synced-tabs" + ); +}); +add_task(asyncCleanup); + +// When Sync is configured in an unverified state. +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_NOT_VERIFIED, + email: "foo@bar.com", + }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-unverified", "synced-tabs"); +}); +add_task(asyncCleanup); + +// When Sync is configured in a "needs reauthentication" state. +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_LOGIN_FAILED, + email: "foo@bar.com", + }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs"); +}); + +// Test the Connect Another Device button +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "foo@bar.com", + lastSync: new Date(), + }); + + let button = document.getElementById( + "PanelUI-remotetabs-connect-device-button" + ); + ok(button, "found the button"); + + await document.getElementById("nav-bar").overflowable.show(); + let expectedUrl = + "https://example.com/connect_another_device?context=" + + "fx_desktop_v3&entrypoint=synced-tabs&service=sync&uid=uid&email=foo%40bar.com"; + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedUrl); + button.click(); + // the panel should have been closed. + ok(!isOverflowOpen(), "click closed the panel"); + await promiseTabOpened; + + gBrowser.removeTab(gBrowser.selectedTab); +}); + +// Test the "Sync Now" button +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "foo@bar.com", + lastSync: new Date(), + }); + + await document.getElementById("nav-bar").overflowable.show(); + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + let syncButton = document.getElementById("sync-button"); + syncButton.click(); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + + let subpanel = document.getElementById("PanelUI-remotetabs-main"); + ok(!subpanel.hidden, "main pane is visible"); + let deck = document.getElementById("PanelUI-remotetabs-deck"); + + // The widget is still fetching tabs, as we've neutered everything that + // provides them + is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible"); + + // Tell the widget there are tabs available, but with zero clients. + mockedInternal.getTabClients = () => { + return Promise.resolve([]); + }; + mockedInternal.hasSyncedThisSession = true; + await updateTabsPanel(); + // The UI should be showing the "no clients" pane. + is( + deck.selectedIndex, + DECKINDEX_NOCLIENTS, + "no-clients deck entry is visible" + ); + + // Tell the widget there are tabs available - we have 3 clients, one with no + // tabs. + mockedInternal.getTabClients = () => { + return Promise.resolve([ + { + id: "guid_mobile", + type: "client", + name: "My Phone", + lastModified: 1492201200, + tabs: [], + }, + { + id: "guid_desktop", + type: "client", + name: "My Desktop", + lastModified: 1492201200, + tabs: [ + { + title: "http://example.com/10", + lastUsed: 10, // the most recent + }, + { + title: "http://example.com/1", + lastUsed: 1, // the least recent. + }, + { + title: "http://example.com/5", + lastUsed: 5, + }, + ], + }, + { + id: "guid_second_desktop", + name: "My Other Desktop", + lastModified: 1492201200, + tabs: [ + { + title: "http://example.com/6", + lastUsed: 6, + }, + ], + }, + ]); + }; + await updateTabsPanel(); + + // The UI should be showing tabs! + is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible"); + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild; + // First entry should be the client with the most-recent tab. + is(node.nodeName, "vbox"); + let currentClient = node; + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Desktop", "correct client"); + // Next entry is the most-recent tab + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/10"); + + // Next entry is the next-most-recent tab + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/5"); + + // Next entry is the least-recent tab from the first client. + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/1"); + node = node.nextElementSibling; + is(node, null, "no more siblings"); + + // Next is a toolbarseparator between the clients. + node = currentClient.nextElementSibling; + is(node.nodeName, "toolbarseparator"); + + // Next is the container for client 2. + node = node.nextElementSibling; + is(node.nodeName, "vbox"); + currentClient = node; + + // Next is the client with 1 tab. + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Other Desktop", "correct client"); + // Its single tab + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/6"); + node = node.nextElementSibling; + is(node, null, "no more siblings"); + + // Next is a toolbarseparator between the clients. + node = currentClient.nextElementSibling; + is(node.nodeName, "toolbarseparator"); + + // Next is the container for client 3. + node = node.nextElementSibling; + is(node.nodeName, "vbox"); + currentClient = node; + + // Next is the client with no tab. + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Phone", "correct client"); + // There is a single node saying there's no tabs for the client. + node = node.nextElementSibling; + is(node.nodeName, "label", "node is a label"); + is(node.getAttribute("itemtype"), "", "node is neither a tab nor a client"); + + node = node.nextElementSibling; + is(node, null, "no more siblings"); + is(currentClient.nextElementSibling, null, "no more clients"); + + // Check accessibility. There should be containers for each client, with an + // aria attribute that identifies the client name. + let clientContainers = [ + ...tabList.querySelectorAll("[aria-labelledby]").values(), + ]; + let labelIds = clientContainers.map(container => + container.getAttribute("aria-labelledby") + ); + let labels = labelIds.map(id => document.getElementById(id).textContent); + Assert.deepEqual(labels.sort(), [ + "My Desktop", + "My Other Desktop", + "My Phone", + ]); + + let didSync = false; + let oldDoSync = gSync.doSync; + gSync.doSync = function () { + didSync = true; + gSync.doSync = oldDoSync; + }; + + let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); + is(syncNowButton.disabled, false); + syncNowButton.click(); + ok(didSync, "clicking the button called the correct function"); + + await hideOverflow(); +}); + +// Test the pagination capabilities (Show More/All tabs) +add_task(async function () { + mockedInternal.getTabClients = () => { + return Promise.resolve([ + { + id: "guid_desktop", + type: "client", + name: "My Desktop", + lastModified: 1492201200, + tabs: (function () { + let allTabsDesktop = []; + // We choose 77 tabs, because TABS_PER_PAGE is 25, which means + // on the second to last page we should have 22 items shown + // (because we have to show at least NEXT_PAGE_MIN_TABS=5 tabs on the last page) + for (let i = 1; i <= 77; i++) { + allTabsDesktop.push({ title: "Tab #" + i, url: SAMPLE_TAB_URL }); + } + return allTabsDesktop; + })(), + }, + ]); + }; + + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + lastSync: new Date(), + email: "foo@bar.com", + }); + + await document.getElementById("nav-bar").overflowable.show(); + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + let syncButton = document.getElementById("sync-button"); + syncButton.click(); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + + // Check pre-conditions + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + let subpanel = document.getElementById("PanelUI-remotetabs-main"); + ok(!subpanel.hidden, "main pane is visible"); + let deck = document.getElementById("PanelUI-remotetabs-deck"); + is(deck.selectedIndex, DECKINDEX_TABS, "we should be showing tabs"); + + function checkTabsPage(tabsShownCount, showMoreLabel) { + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Desktop", "correct client"); + for (let i = 0; i < tabsShownCount; i++) { + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is( + node.getAttribute("label"), + "Tab #" + (i + 1), + "the tab is the correct one" + ); + is( + node.getAttribute("targetURI"), + SAMPLE_TAB_URL, + "url is the correct one" + ); + } + let showMoreButton; + if (showMoreLabel) { + node = showMoreButton = node.nextElementSibling; + is( + node.getAttribute("itemtype"), + "showmorebutton", + "node is a show more button" + ); + is(node.getAttribute("label"), showMoreLabel); + } + node = node.nextElementSibling; + is(node, null, "no more entries"); + + return showMoreButton; + } + + async function checkCanOpenURL() { + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild.firstElementChild.nextElementSibling; + let promiseTabOpened = BrowserTestUtils.waitForLocationChange( + gBrowser, + SAMPLE_TAB_URL + ); + node.click(); + await promiseTabOpened; + } + + let showMoreButton; + function clickShowMoreButton() { + let promise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated"); + showMoreButton.click(); + return promise; + } + + showMoreButton = checkTabsPage(25, "Show More Tabs"); + await clickShowMoreButton(); + + checkTabsPage(77, null); + /* calling this will close the overflow menu */ + await checkCanOpenURL(); +}); diff --git a/browser/components/customizableui/test/browser_tabbar_big_widgets.js b/browser/components/customizableui/test/browser_tabbar_big_widgets.js new file mode 100644 index 0000000000..2eae968656 --- /dev/null +++ b/browser/components/customizableui/test/browser_tabbar_big_widgets.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const kButtonId = "test-tabbar-size-with-large-buttons"; + +function test() { + registerCleanupFunction(cleanup); + let titlebar = document.getElementById("titlebar"); + let originalHeight = titlebar.getBoundingClientRect().height; + let button = document.createXULElement("toolbarbutton"); + button.id = kButtonId; + button.setAttribute("style", "min-height: 100px"); + gNavToolbox.palette.appendChild(button); + CustomizableUI.addWidgetToArea(kButtonId, CustomizableUI.AREA_TABSTRIP); + let currentHeight = titlebar.getBoundingClientRect().height; + Assert.greater(currentHeight, originalHeight, "Titlebar should have grown"); + CustomizableUI.removeWidgetFromArea(kButtonId); + currentHeight = titlebar.getBoundingClientRect().height; + is( + currentHeight, + originalHeight, + "Titlebar should have gone back to its original size." + ); +} + +function cleanup() { + let btn = document.getElementById(kButtonId); + if (btn) { + btn.remove(); + } +} diff --git a/browser/components/customizableui/test/browser_toolbar_collapsed_states.js b/browser/components/customizableui/test/browser_toolbar_collapsed_states.js new file mode 100644 index 0000000000..23da60a7d5 --- /dev/null +++ b/browser/components/customizableui/test/browser_toolbar_collapsed_states.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Checks that CustomizableUI reports the expected collapsed toolbar IDs. + * + * Note: on macOS, expectations for CustomizableUI.AREA_MENUBAR are + * automatically skipped since that area isn't available on that platform. + * + * @param {string[]} The IDs of the expected collapsed toolbars. + */ +function assertCollapsedToolbarIds(expected) { + if (AppConstants.platform == "macosx") { + let menubarIndex = expected.indexOf(CustomizableUI.AREA_MENUBAR); + if (menubarIndex != -1) { + expected.splice(menubarIndex, 1); + } + } + + let collapsedIds = CustomizableUI.getCollapsedToolbarIds(window); + Assert.equal(collapsedIds.size, expected.length); + for (let expectedId of expected) { + Assert.ok( + collapsedIds.has(expectedId), + `${expectedId} should be collapsed` + ); + } +} + +registerCleanupFunction(async () => { + await CustomizableUI.reset(); +}); + +/** + * Tests that CustomizableUI.getCollapsedToolbarIds will return the IDs of + * toolbars that are collapsed, or menubars that are autohidden. + */ +add_task(async function test_toolbar_collapsed_states() { + // By default, we expect the menubar and the bookmarks toolbar to be + // collapsed. + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + let bookmarksToolbar = document.getElementById(CustomizableUI.AREA_BOOKMARKS); + // Make sure we're configured to show the bookmarks toolbar on about:newtab. + setToolbarVisibility(bookmarksToolbar, "newtab"); + + let newTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:newtab", + waitForLoad: false, + }); + // Now that we've opened about:newtab, the bookmarks toolbar should now + // be visible. + assertCollapsedToolbarIds([CustomizableUI.AREA_MENUBAR]); + await BrowserTestUtils.removeTab(newTab); + + // And with about:newtab closed again, the bookmarks toolbar should be + // reported as collapsed. + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + // Make sure we're configured to show the bookmarks toolbar on about:newtab. + setToolbarVisibility(bookmarksToolbar, "always"); + assertCollapsedToolbarIds([CustomizableUI.AREA_MENUBAR]); + + setToolbarVisibility(bookmarksToolbar, "never"); + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + if (AppConstants.platform != "macosx") { + // We'll still consider the menubar collapsed by default, even if it's being temporarily + // shown via the alt key. + let menubarActive = BrowserTestUtils.waitForEvent( + window, + "DOMMenuBarActive" + ); + EventUtils.synthesizeKey("VK_ALT", {}); + await menubarActive; + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + let menubarInactive = BrowserTestUtils.waitForEvent( + window, + "DOMMenuBarInactive" + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}); + await menubarInactive; + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + let menubar = document.getElementById(CustomizableUI.AREA_MENUBAR); + setToolbarVisibility(menubar, true); + assertCollapsedToolbarIds([CustomizableUI.AREA_BOOKMARKS]); + setToolbarVisibility(menubar, false); + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + } +}); diff --git a/browser/components/customizableui/test/browser_touchbar_customization.js b/browser/components/customizableui/test/browser_touchbar_customization.js new file mode 100644 index 0000000000..106c202ad9 --- /dev/null +++ b/browser/components/customizableui/test/browser_touchbar_customization.js @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks if the Customize Touch Bar button appears when a Touch Bar is +// initialized. +add_task(async function customizeTouchBarButtonAppears() { + let updater = Cc["@mozilla.org/widget/touchbarupdater;1"].getService( + Ci.nsITouchBarUpdater + ); + // This value will be reset to its default the next time a window is opened. + updater.setTouchBarInitialized(true); + await startCustomizing(); + let touchbarButton = document.querySelector("#customization-touchbar-button"); + ok(!touchbarButton.hidden, "Customize Touch Bar button is not hidden."); + let touchbarSpacer = document.querySelector("#customization-touchbar-spacer"); + ok(!touchbarSpacer.hidden, "Customize Touch Bar spacer is not hidden."); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_unified_extensions_reset.js b/browser/components/customizableui/test/browser_unified_extensions_reset.js new file mode 100644 index 0000000000..fdee8cf76a --- /dev/null +++ b/browser/components/customizableui/test/browser_unified_extensions_reset.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if Unified Extensions UI is enabled that resetting the toolbars + * puts all browser action buttons into the AREA_ADDONS area. + */ +add_task(async function test_reset_with_unified_extensions_ui() { + const kWebExtensionWidgetIDs = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + "ext3-browser-action", + "ext4-browser-action", + "ext5-browser-action", + "ext6-browser-action", + "ext7-browser-action", + "ext8-browser-action", + "ext9-browser-action", + "ext10-browser-action", + ]; + + for (let widgetID of kWebExtensionWidgetIDs) { + CustomizableUI.createWidget({ + id: widgetID, + label: "Test extension widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + } + + // Now let's put these browser actions in a bunch of different places. + // Regardless of where they go, we're going to expect them in AREA_ADDONS + // after we reset. + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[0], + CustomizableUI.AREA_TABSTRIP + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[1], + CustomizableUI.AREA_TABSTRIP + ); + + // macOS doesn't have AREA_MENUBAR registered, so we'll leave these widgets + // behind in the AREA_NAVBAR there, and put them into the menubar on the + // other platforms. + if (AppConstants.platform != "macosx") { + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[2], + CustomizableUI.AREA_MENUBAR + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[3], + CustomizableUI.AREA_MENUBAR + ); + } + + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[4], + CustomizableUI.AREA_BOOKMARKS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[5], + CustomizableUI.AREA_BOOKMARKS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[6], + CustomizableUI.AREA_ADDONS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[7], + CustomizableUI.AREA_ADDONS + ); + + CustomizableUI.reset(); + + // Let's force the Unified Extensions panel to register itself now if it + // wasn't already done. Using the getter should be sufficient. + Assert.ok(gUnifiedExtensions.panel, "Should have found the panel."); + + for (let widgetID of kWebExtensionWidgetIDs) { + let { area } = CustomizableUI.getPlacementOfWidget(widgetID); + Assert.equal(area, CustomizableUI.AREA_ADDONS); + // Let's double-check that they're actually in there in the DOM too. + let widget = CustomizableUI.getWidget(widgetID).forWindow(window); + Assert.equal(widget.node.parentElement.id, CustomizableUI.AREA_ADDONS); + CustomizableUI.destroyWidget(widgetID); + } +}); diff --git a/browser/components/customizableui/test/browser_widget_animation.js b/browser/components/customizableui/test/browser_widget_animation.js new file mode 100644 index 0000000000..514e3f763b --- /dev/null +++ b/browser/components/customizableui/test/browser_widget_animation.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +gReduceMotionOverride = false; + +function promiseWidgetAnimationOut(aNode) { + let animationNode = aNode; + if ( + animationNode.tagName != "toolbaritem" && + animationNode.tagName != "toolbarbutton" + ) { + animationNode = animationNode.closest("toolbaritem"); + } + if (animationNode.parentNode.id.startsWith("wrapper-")) { + animationNode = animationNode.parentNode; + } + return new Promise(resolve => { + animationNode.addEventListener( + "animationend", + function cleanupWidgetAnimationOut(e) { + if ( + e.animationName == "widget-animate-out" && + e.target.id == animationNode.id + ) { + animationNode.removeEventListener( + "animationend", + cleanupWidgetAnimationOut + ); + ok(true, "The widget`s animationend should have happened"); + resolve(); + } + } + ); + }); +} + +function promiseOverflowAnimationEnd() { + return new Promise(resolve => { + let overflowButton = document.getElementById("nav-bar-overflow-button"); + overflowButton.addEventListener( + "animationend", + function cleanupOverflowAnimationOut(event) { + if (event.animationName == "overflow-animation") { + overflowButton.removeEventListener( + "animationend", + cleanupOverflowAnimationOut + ); + ok( + true, + "The overflow button`s animationend event should have happened" + ); + resolve(); + } + } + ); + }); +} + +// Right-click on the stop/reload button, use the context menu to move it to the overflow menu. +// The button should animate out, and the overflow menu should animate upon adding. +add_task(async function () { + let stopReloadButton = document.getElementById("stop-reload-button"); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouseAtCenter(stopReloadButton, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + contextMenu.activateItem( + contextMenu.querySelector(".customize-context-moveToPanel") + ); + + await Promise.all([ + promiseWidgetAnimationOut(stopReloadButton), + promiseOverflowAnimationEnd(), + ]); + ok(true, "The widget and overflow animations should have both happened."); +}); + +registerCleanupFunction(CustomizableUI.reset); diff --git a/browser/components/customizableui/test/browser_widget_recreate_events.js b/browser/components/customizableui/test/browser_widget_recreate_events.js new file mode 100644 index 0000000000..3eca9231a8 --- /dev/null +++ b/browser/components/customizableui/test/browser_widget_recreate_events.js @@ -0,0 +1,99 @@ +"use strict"; + +const widgetData = { + id: "test-widget", + type: "view", + viewId: "PanelUI-testbutton", + label: "test widget label", + onViewShowing() {}, + onViewHiding() {}, +}; + +async function simulateWidgetOpen() { + let testWidgetButton = document.getElementById("test-widget"); + let testWidgetShowing = BrowserTestUtils.waitForEvent( + document, + "popupshowing", + true + ); + testWidgetButton.click(); + await testWidgetShowing; +} + +async function simulateWidgetClose() { + let panel = document.getElementById("customizationui-widget-panel"); + let panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + + panel.hidePopup(); + await panelHidden; +} + +function createPanelView() { + let panelView = document.createXULElement("panelview"); + panelView.id = "PanelUI-testbutton"; + let vbox = document.createXULElement("vbox"); + panelView.appendChild(vbox); + return panelView; +} + +/** + * Check that panel view/hide events are added back, + * if widget is destroyed and created again in one session. + */ +add_task(async function () { + let viewCache = document.getElementById("appMenu-viewCache"); + let panelView = createPanelView(); + viewCache.appendChild(panelView); + + CustomizableUI.createWidget(widgetData); + CustomizableUI.addWidgetToArea("test-widget", "nav-bar"); + + // Simulate clicking and wait for the open + // so we ensure the lazy event creation is done. + await simulateWidgetOpen(); + + let listeners = Services.els.getListenerInfoFor(panelView); + ok( + listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event added" + ); + ok( + listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event added" + ); + + await simulateWidgetClose(); + CustomizableUI.destroyWidget("test-widget"); + + listeners = Services.els.getListenerInfoFor(panelView); + // Ensure the events got removed after destorying the widget. + ok( + !listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event removed" + ); + ok( + !listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event removed" + ); + + CustomizableUI.createWidget(widgetData); + // Simulate clicking and wait for the open + // so we ensure the lazy event creation is done. + // We need to do this again because we destroyed the widget. + await simulateWidgetOpen(); + + listeners = Services.els.getListenerInfoFor(panelView); + ok( + listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event added again" + ); + ok( + listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event added again" + ); + + await simulateWidgetClose(); + CustomizableUI.destroyWidget("test-widget"); + panelView.remove(); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/dummy_history_item.html b/browser/components/customizableui/test/dummy_history_item.html new file mode 100644 index 0000000000..23a6992923 --- /dev/null +++ b/browser/components/customizableui/test/dummy_history_item.html @@ -0,0 +1,2 @@ +<title>Happy History Hero</title> +<p>I am a page for the history books.</p> diff --git a/browser/components/customizableui/test/head.js b/browser/components/customizableui/test/head.js new file mode 100644 index 0000000000..f8c0d02a12 --- /dev/null +++ b/browser/components/customizableui/test/head.js @@ -0,0 +1,530 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.sys.mjs", +}); + +/** + * Instance of CustomizableUITestUtils for the current browser window. + */ +var gCUITestUtils = new CustomizableUITestUtils(window); + +Services.prefs.setBoolPref("browser.uiCustomization.skipSourceNodeCheck", true); +registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.uiCustomization.skipSourceNodeCheck") +); + +var { synthesizeDrop, synthesizeMouseAtCenter } = EventUtils; + +const kForceOverflowWidthPx = 450; + +function createDummyXULButton(id, label, win = window) { + let btn = win.document.createXULElement("toolbarbutton"); + btn.id = id; + btn.setAttribute("label", label || id); + btn.className = "toolbarbutton-1 chromeclass-toolbar-additional"; + win.gNavToolbox.palette.appendChild(btn); + return btn; +} + +var gAddedToolbars = new Set(); + +function createToolbarWithPlacements(id, placements = [], properties = {}) { + gAddedToolbars.add(id); + let tb = document.createXULElement("toolbar"); + tb.id = id; + tb.setAttribute("customizable", "true"); + + properties.type = CustomizableUI.TYPE_TOOLBAR; + properties.defaultPlacements = placements; + CustomizableUI.registerArea(id, properties); + gNavToolbox.appendChild(tb); + CustomizableUI.registerToolbarNode(tb); + return tb; +} + +function createOverflowableToolbarWithPlacements(id, placements) { + gAddedToolbars.add(id); + + let tb = document.createXULElement("toolbar"); + tb.id = id; + tb.setAttribute("customizationtarget", id + "-target"); + + let customizationtarget = document.createXULElement("hbox"); + customizationtarget.id = id + "-target"; + customizationtarget.setAttribute("flex", "1"); + tb.appendChild(customizationtarget); + + let overflowPanel = document.createXULElement("panel"); + overflowPanel.id = id + "-overflow"; + document.getElementById("mainPopupSet").appendChild(overflowPanel); + + let overflowList = document.createXULElement("vbox"); + overflowList.id = id + "-overflow-list"; + overflowPanel.appendChild(overflowList); + + let chevron = document.createXULElement("toolbarbutton"); + chevron.id = id + "-chevron"; + tb.appendChild(chevron); + + CustomizableUI.registerArea(id, { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: placements, + overflowable: true, + }); + + tb.setAttribute("customizable", "true"); + tb.setAttribute("overflowable", "true"); + tb.setAttribute("default-overflowpanel", overflowPanel.id); + tb.setAttribute("default-overflowtarget", overflowList.id); + tb.setAttribute("default-overflowbutton", chevron.id); + tb.setAttribute("addon-webext-overflowbutton", "unified-extensions-button"); + tb.setAttribute("addon-webext-overflowtarget", "overflowed-extensions-list"); + + gNavToolbox.appendChild(tb); + CustomizableUI.registerToolbarNode(tb); + return tb; +} + +function removeCustomToolbars() { + CustomizableUI.reset(); + for (let toolbarId of gAddedToolbars) { + CustomizableUI.unregisterArea(toolbarId, true); + let tb = document.getElementById(toolbarId); + if (tb.hasAttribute("overflowpanel")) { + let panel = document.getElementById(tb.getAttribute("overflowpanel")); + if (panel) { + panel.remove(); + } + } + tb.remove(); + } + gAddedToolbars.clear(); +} + +function resetCustomization() { + return CustomizableUI.reset(); +} + +function isInDevEdition() { + return AppConstants.MOZ_DEV_EDITION; +} + +function removeNonReleaseButtons(areaPanelPlacements) { + if (isInDevEdition() && areaPanelPlacements.includes("developer-button")) { + areaPanelPlacements.splice( + areaPanelPlacements.indexOf("developer-button"), + 1 + ); + } +} + +function removeNonOriginalButtons() { + CustomizableUI.removeWidgetFromArea("sync-button"); +} + +function assertAreaPlacements(areaId, expectedPlacements) { + let actualPlacements = getAreaWidgetIds(areaId); + placementArraysEqual(areaId, actualPlacements, expectedPlacements); +} + +function placementArraysEqual(areaId, actualPlacements, expectedPlacements) { + info("Actual placements: " + actualPlacements.join(", ")); + info("Expected placements: " + expectedPlacements.join(", ")); + is( + actualPlacements.length, + expectedPlacements.length, + "Area " + areaId + " should have " + expectedPlacements.length + " items." + ); + let minItems = Math.min(expectedPlacements.length, actualPlacements.length); + for (let i = 0; i < minItems; i++) { + if (typeof expectedPlacements[i] == "string") { + is( + actualPlacements[i], + expectedPlacements[i], + "Item " + i + " in " + areaId + " should match expectations." + ); + } else if (expectedPlacements[i] instanceof RegExp) { + ok( + expectedPlacements[i].test(actualPlacements[i]), + "Item " + + i + + " (" + + actualPlacements[i] + + ") in " + + areaId + + " should match " + + expectedPlacements[i] + ); + } else { + ok( + false, + "Unknown type of expected placement passed to " + + " assertAreaPlacements. Is your test broken?" + ); + } + } +} + +function todoAssertAreaPlacements(areaId, expectedPlacements) { + let actualPlacements = getAreaWidgetIds(areaId); + let isPassing = actualPlacements.length == expectedPlacements.length; + let minItems = Math.min(expectedPlacements.length, actualPlacements.length); + for (let i = 0; i < minItems; i++) { + if (typeof expectedPlacements[i] == "string") { + isPassing = isPassing && actualPlacements[i] == expectedPlacements[i]; + } else if (expectedPlacements[i] instanceof RegExp) { + isPassing = isPassing && expectedPlacements[i].test(actualPlacements[i]); + } else { + ok( + false, + "Unknown type of expected placement passed to " + + " assertAreaPlacements. Is your test broken?" + ); + } + } + todo( + isPassing, + "The area placements for " + + areaId + + " should equal the expected placements." + ); +} + +function getAreaWidgetIds(areaId) { + return CustomizableUI.getWidgetIdsInArea(areaId); +} + +function simulateItemDrag(aToDrag, aTarget, aEvent = {}, aOffset = 2) { + let ev = aEvent; + if (ev == "end" || ev == "start") { + let win = aTarget.ownerGlobal; + const dwu = win.windowUtils; + let bounds = dwu.getBoundsWithoutFlushing(aTarget); + if (ev == "end") { + ev = { + clientX: bounds.right - aOffset, + clientY: bounds.bottom - aOffset, + }; + } else { + ev = { clientX: bounds.left + aOffset, clientY: bounds.top + aOffset }; + } + } + ev._domDispatchOnly = true; + synthesizeDrop( + aToDrag.parentNode, + aTarget, + null, + null, + aToDrag.ownerGlobal, + aTarget.ownerGlobal, + ev + ); + // Ensure dnd suppression is cleared. + synthesizeMouseAtCenter(aTarget, { type: "mouseup" }, aTarget.ownerGlobal); +} + +function endCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") != "true") { + return true; + } + let afterCustomizationPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "aftercustomization" + ); + aWindow.gCustomizeMode.exit(); + return afterCustomizationPromise; +} + +function startCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") == "true") { + return null; + } + let customizationReadyPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "customizationready" + ); + aWindow.gCustomizeMode.enter(); + return customizationReadyPromise; +} + +function promiseObserverNotified(aTopic) { + return new Promise(resolve => { + Services.obs.addObserver(function onNotification(subject, topic, data) { + Services.obs.removeObserver(onNotification, topic); + resolve({ subject, data }); + }, aTopic); + }); +} + +function openAndLoadWindow(aOptions, aWaitForDelayedStartup = false) { + return new Promise(resolve => { + let win = OpenBrowserWindow(aOptions); + if (aWaitForDelayedStartup) { + Services.obs.addObserver(function onDS(aSubject, aTopic, aData) { + if (aSubject != win) { + return; + } + Services.obs.removeObserver(onDS, "browser-delayed-startup-finished"); + resolve(win); + }, "browser-delayed-startup-finished"); + } else { + win.addEventListener( + "load", + function () { + resolve(win); + }, + { once: true } + ); + } + }); +} + +function promiseWindowClosed(win) { + return new Promise(resolve => { + win.addEventListener( + "unload", + function () { + resolve(); + }, + { once: true } + ); + win.close(); + }); +} + +function promiseOverflowShown(win) { + let panelEl = win.document.getElementById("widget-overflow"); + return promisePanelElementShown(win, panelEl); +} + +function promisePanelElementShown(win, aPanel) { + return new Promise((resolve, reject) => { + let timeoutId = win.setTimeout(() => { + reject("Panel did not show within 20 seconds."); + }, 20000); + function onPanelOpen(e) { + aPanel.removeEventListener("popupshown", onPanelOpen); + win.clearTimeout(timeoutId); + resolve(); + } + aPanel.addEventListener("popupshown", onPanelOpen); + }); +} + +function promiseOverflowHidden(win) { + let panelEl = win.PanelUI.overflowPanel; + return promisePanelElementHidden(win, panelEl); +} + +function promisePanelElementHidden(win, aPanel) { + return new Promise((resolve, reject) => { + let timeoutId = win.setTimeout(() => { + reject("Panel did not hide within 20 seconds."); + }, 20000); + function onPanelClose(e) { + aPanel.removeEventListener("popuphidden", onPanelClose); + win.clearTimeout(timeoutId); + executeSoon(resolve); + } + aPanel.addEventListener("popuphidden", onPanelClose); + }); +} + +function isPanelUIOpen() { + return PanelUI.panel.state == "open" || PanelUI.panel.state == "showing"; +} + +function isOverflowOpen() { + let panel = document.getElementById("widget-overflow"); + return panel.state == "open" || panel.state == "showing"; +} + +function subviewShown(aSubview) { + return new Promise((resolve, reject) => { + let win = aSubview.ownerGlobal; + let timeoutId = win.setTimeout(() => { + reject("Subview (" + aSubview.id + ") did not show within 20 seconds."); + }, 20000); + function onViewShown(e) { + aSubview.removeEventListener("ViewShown", onViewShown); + win.clearTimeout(timeoutId); + resolve(); + } + aSubview.addEventListener("ViewShown", onViewShown); + }); +} + +function subviewHidden(aSubview) { + return new Promise((resolve, reject) => { + let win = aSubview.ownerGlobal; + let timeoutId = win.setTimeout(() => { + reject("Subview (" + aSubview.id + ") did not hide within 20 seconds."); + }, 20000); + function onViewHiding(e) { + aSubview.removeEventListener("ViewHiding", onViewHiding); + win.clearTimeout(timeoutId); + resolve(); + } + aSubview.addEventListener("ViewHiding", onViewHiding); + }); +} + +function waitFor(aTimeout = 100) { + return new Promise(resolve => { + setTimeout(() => resolve(), aTimeout); + }); +} + +/** + * Starts a load in an existing tab and waits for it to finish (via some event). + * + * @param aTab The tab to load into. + * @param aUrl The url to load. + * @param aEventType The load event type to wait for. Defaults to "load". + * @return {Promise} resolved when the event is handled. + */ +function promiseTabLoadEvent(aTab, aURL) { + let browser = aTab.linkedBrowser; + + BrowserTestUtils.startLoadingURIString(browser, aURL); + return BrowserTestUtils.browserLoaded(browser); +} + +/** + * Wait for an attribute on a node to change + * + * @param aNode Node on which the mutation is expected + * @param aAttribute The attribute we're interested in + * @param aFilterFn A function to check if the new value is what we want. + * @return {Promise} resolved when the requisite mutation shows up. + */ +function promiseAttributeMutation(aNode, aAttribute, aFilterFn) { + return new Promise((resolve, reject) => { + info("waiting for mutation of attribute '" + aAttribute + "'."); + let obs = new MutationObserver(mutations => { + for (let mut of mutations) { + let attr = mut.attributeName; + let newValue = mut.target.getAttribute(attr); + if (aFilterFn(newValue)) { + ok( + true, + "mutation occurred: attribute '" + + attr + + "' changed to '" + + newValue + + "' from '" + + mut.oldValue + + "'." + ); + obs.disconnect(); + resolve(); + } else { + info( + "Ignoring mutation that produced value " + + newValue + + " because of filter." + ); + } + } + }); + obs.observe(aNode, { attributeFilter: [aAttribute] }); + }); +} + +function popupShown(aPopup) { + return BrowserTestUtils.waitForPopupEvent(aPopup, "shown"); +} + +function popupHidden(aPopup) { + return BrowserTestUtils.waitForPopupEvent(aPopup, "hidden"); +} + +// This is a simpler version of the context menu check that +// exists in contextmenu_common.js. +function checkContextMenu(aContextMenu, aExpectedEntries, aWindow = window) { + let children = [...aContextMenu.children]; + // Ignore hidden nodes: + children = children.filter(n => !n.hidden); + + for (let i = 0; i < children.length; i++) { + let menuitem = children[i]; + try { + if (aExpectedEntries[i][0] == "---") { + is(menuitem.localName, "menuseparator", "menuseparator expected"); + continue; + } + + let selector = aExpectedEntries[i][0]; + ok( + menuitem.matches(selector), + "menuitem should match " + selector + " selector" + ); + let commandValue = menuitem.getAttribute("command"); + let relatedCommand = commandValue + ? aWindow.document.getElementById(commandValue) + : null; + let menuItemDisabled = relatedCommand + ? relatedCommand.getAttribute("disabled") == "true" + : menuitem.getAttribute("disabled") == "true"; + is( + menuItemDisabled, + !aExpectedEntries[i][1], + "disabled state for " + selector + ); + } catch (e) { + ok(false, "Exception when checking context menu: " + e); + } + } +} + +function waitForOverflowButtonShown(win = window) { + info("Waiting for overflow button to show"); + let ov = win.document.getElementById("nav-bar-overflow-button"); + return waitForElementShown(ov.icon); +} +function waitForElementShown(element) { + return BrowserTestUtils.waitForCondition(() => { + info("Checking if element has non-0 size"); + // We intentionally flush layout to ensure the element is actually shown. + let rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); +} + +/** + * Opens the history panel through the history toolbarbutton in the + * navbar and returns a promise that resolves as soon as the panel is open + * is showing. + */ +async function openHistoryPanel(doc = document) { + await waitForOverflowButtonShown(); + await doc.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let historyButton = doc.getElementById("history-panelmenu"); + Assert.ok(historyButton, "History button appears in Panel Menu"); + + historyButton.click(); + + let historyPanel = doc.getElementById("PanelUI-history"); + return BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); +} + +/** + * Closes the history panel and returns a promise that resolves as sooon + * as the panel is closed. + */ +async function hideHistoryPanel(doc = document) { + let historyView = doc.getElementById("PanelUI-history"); + let historyPanel = historyView.closest("panel"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "popuphidden"); + historyPanel.hidePopup(); + return promise; +} diff --git a/browser/components/customizableui/test/support/test_967000_charEncoding_page.html b/browser/components/customizableui/test/support/test_967000_charEncoding_page.html new file mode 100644 index 0000000000..7932b16f12 --- /dev/null +++ b/browser/components/customizableui/test/support/test_967000_charEncoding_page.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="iso-8859-1"> + <title>Test page</title> + </head> + + <body> + This is a test page + </body> +</html> diff --git a/browser/components/customizableui/test/unit/test_unified_extensions_migration.js b/browser/components/customizableui/test/unit/test_unified_extensions_migration.js new file mode 100644 index 0000000000..022b783d70 --- /dev/null +++ b/browser/components/customizableui/test/unit/test_unified_extensions_migration.js @@ -0,0 +1,373 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We're in an xpcshell test but have an eslint browser test env applied; +// We definitely do need to manually import CustomizableUI. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +do_get_profile(); + +// Make Cu.isInAutomation true. This is necessary so that we can use +// CustomizableUIInternal. +Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + true +); + +const CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" +); + +// Migration 19 was the Unified Extensions migration version introduced +// in 109, so we'll run tests by artificially setting the migration version +// to one value earlier. +const PRIOR_MIGRATION_VERSION = 18; + +/** + * Writes customization state into CustomizableUI and then performs the forward migration + * for Unified Extensions. + * + * @param {object|null} stateObj An object that will be structure-cloned and + * written into CustomizableUI's internal `gSavedState` state variable. Should + * not include the currentVersion property, as this will be set automatically by + * function if stateObj is not null. + * @returns {object} + * the saved state object (minus the currentVersion property). + */ +function migrateForward(stateObj) { + // We make sure to use structuredClone here so that we don't end up comparing + // SAVED_STATE against itself. + let stateToSave = structuredClone(stateObj); + if (stateToSave) { + stateToSave.currentVersion = PRIOR_MIGRATION_VERSION; + } + + CustomizableUI.setTestOnlyInternalProp("gSavedState", stateToSave); + CustomizableUIInternal._updateForNewVersion(); + + let migratedState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + if (migratedState) { + delete migratedState.currentVersion; + } + return migratedState; +} + +/** + * Test that attempting a migration on a new profile with no saved + * state exits safely. + */ +add_task(async function test_no_saved_state() { + let migratedState = migrateForward(null); + + Assert.deepEqual( + migratedState, + null, + "gSavedState should not have been modified" + ); +}); + +/** + * Test that attempting a migration on a new profile with no saved + * state exits safely. + */ +add_task(async function test_no_saved_placements() { + let migratedState = migrateForward({}); + + Assert.deepEqual( + migratedState, + {}, + "gSavedState should not have been modified" + ); +}); + +/** + * Test that placements that don't involve any extension buttons are + * not changed during the migration. + */ +add_task(async function test_no_extensions() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": ["privatebrowsing-button", "panic-button"], + }, + }; + + // ADDONS_AREA should end up with an empty array as its set of placements. + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = []; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "Got the expected state after the migration." + ); +}); + +/** + * Test that if there's an existing set of items in CustomizableUI.AREA_ADDONS, + * and no extension buttons to migrate from the overflow menu, then we don't + * change the state at all. + */ +add_task(async function test_existing_browser_actions_no_movement() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": ["privatebrowsing-button", "panic-button"], + "unified-extensions-area": ["ext0-browser-action", "ext1-browser-action"], + }, + }; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + SAVED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that we can migrate extension buttons out from the overflow panel + * into the addons panel. + */ +add_task(async function test_migrate_extension_buttons() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": [ + "ext0-browser-action", + "privatebrowsing-button", + "ext1-browser-action", + "panic-button", + "ext2-browser-action", + ], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that we won't overwrite existing placements within the addons panel + * if we migrate things over from the overflow panel. We'll prepend the + * migrated items to the addons panel instead. + */ +add_task(async function test_migrate_extension_buttons_no_overwrite() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": [ + "ext0-browser-action", + "privatebrowsing-button", + "ext1-browser-action", + "panic-button", + "ext2-browser-action", + ], + "unified-extensions-area": ["ext3-browser-action", "ext4-browser-action"], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + "ext3-browser-action", + "ext4-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that extension buttons from areas other than the overflow panel + * won't be moved. + */ +add_task(async function test_migrate_extension_buttons_elsewhere() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "ext0-browser-action", + "forward-button", + "ext1-browser-action", + "spring", + "ext2-browser-action", + "urlbar-container", + "ext3-browser-action", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + "ext4-browser-action", + ], + "toolbar-menubar": [ + "home-button", + "ext5-browser-action", + "menubar-items", + "ext6-browser-action", + "spring", + "ext7-browser-action", + "downloads-button", + "ext8-browser-action", + ], + TabsToolbar: [ + "firefox-view-button", + "ext9-browser-action", + "tabbrowser-tabs", + "ext10-browser-action", + "new-tab-button", + "ext11-browser-action", + "alltabs-button", + "ext12-browser-action", + "developer-button", + "ext13-browser-action", + ], + PersonalToolbar: [ + "personal-bookmarks", + "ext14-browser-action", + "fxa-toolbar-menu-button", + "ext15-browser-action", + ], + "widget-overflow-fixed-list": [ + "ext16-browser-action", + "privatebrowsing-button", + "ext17-browser-action", + "panic-button", + "ext18-browser-action", + ], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext16-browser-action", + "ext17-browser-action", + "ext18-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); diff --git a/browser/components/customizableui/test/unit/xpcshell.toml b/browser/components/customizableui/test/unit/xpcshell.toml new file mode 100644 index 0000000000..029a8a962d --- /dev/null +++ b/browser/components/customizableui/test/unit/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] +head = '' +skip-if = ["os == 'android'"] # bug 1730213 +firefox-appdir = "browser" + +["test_unified_extensions_migration.js"] |