summaryrefslogtreecommitdiffstats
path: root/browser/components/customizableui
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/customizableui')
-rw-r--r--browser/components/customizableui/CustomizableUI.sys.mjs6285
-rw-r--r--browser/components/customizableui/CustomizableWidgets.sys.mjs615
-rw-r--r--browser/components/customizableui/CustomizeMode.sys.mjs2971
-rw-r--r--browser/components/customizableui/DragPositionManager.sys.mjs313
-rw-r--r--browser/components/customizableui/PanelMultiView.sys.mjs1894
-rw-r--r--browser/components/customizableui/SearchWidgetTracker.sys.mjs134
-rw-r--r--browser/components/customizableui/content/.eslintrc.js13
-rw-r--r--browser/components/customizableui/content/customizeMode.inc.xhtml121
-rw-r--r--browser/components/customizableui/content/jar.mn6
-rw-r--r--browser/components/customizableui/content/moz.build7
-rw-r--r--browser/components/customizableui/content/panelUI.inc.xhtml329
-rw-r--r--browser/components/customizableui/content/panelUI.js1072
-rw-r--r--browser/components/customizableui/moz.build28
-rw-r--r--browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs156
-rw-r--r--browser/components/customizableui/test/browser.toml368
-rw-r--r--browser/components/customizableui/test/browser_1003588_no_specials_in_panel.js133
-rw-r--r--browser/components/customizableui/test/browser_1008559_anchor_undo_restore.js102
-rw-r--r--browser/components/customizableui/test/browser_1042100_default_placements_update.js241
-rw-r--r--browser/components/customizableui/test/browser_1058573_showToolbarsDropdown.js29
-rw-r--r--browser/components/customizableui/test/browser_1087303_button_fullscreen.js55
-rw-r--r--browser/components/customizableui/test/browser_1087303_button_preferences.js59
-rw-r--r--browser/components/customizableui/test/browser_1089591_still_customizable_after_reset.js24
-rw-r--r--browser/components/customizableui/test/browser_1096763_seen_widgets_post_reset.js41
-rw-r--r--browser/components/customizableui/test/browser_1161838_inserted_new_default_buttons.js109
-rw-r--r--browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js69
-rw-r--r--browser/components/customizableui/test/browser_1701883_restore_defaults_pocket_pref.js28
-rw-r--r--browser/components/customizableui/test/browser_1702200_PanelMultiView_header_separator.js76
-rw-r--r--browser/components/customizableui/test/browser_1795260_searchbar_overflow_toolbar.js30
-rw-r--r--browser/components/customizableui/test/browser_1856572_ensure_Fluent_works_in_customizeMode.js62
-rw-r--r--browser/components/customizableui/test/browser_694291_searchbar_preference.js48
-rw-r--r--browser/components/customizableui/test/browser_873501_handle_specials.js89
-rw-r--r--browser/components/customizableui/test/browser_876926_customize_mode_wrapping.js295
-rw-r--r--browser/components/customizableui/test/browser_876944_customize_mode_create_destroy.js44
-rw-r--r--browser/components/customizableui/test/browser_877006_missing_view.js46
-rw-r--r--browser/components/customizableui/test/browser_877178_unregisterArea.js70
-rw-r--r--browser/components/customizableui/test/browser_877447_skip_missing_ids.js35
-rw-r--r--browser/components/customizableui/test/browser_878452_drag_to_panel.js90
-rw-r--r--browser/components/customizableui/test/browser_884402_customize_from_overflow.js117
-rw-r--r--browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js73
-rw-r--r--browser/components/customizableui/test/browser_885530_showInPrivateBrowsing.js141
-rw-r--r--browser/components/customizableui/test/browser_886323_buildArea_removable_nodes.js58
-rw-r--r--browser/components/customizableui/test/browser_890262_destroyWidget_after_add_to_panel.js74
-rw-r--r--browser/components/customizableui/test/browser_892955_isWidgetRemovable_for_removed_widgets.js30
-rw-r--r--browser/components/customizableui/test/browser_892956_destroyWidget_defaultPlacements.js30
-rw-r--r--browser/components/customizableui/test/browser_901207_searchbar_in_panel.js139
-rw-r--r--browser/components/customizableui/test/browser_909779_overflow_toolbars_new_window.js49
-rw-r--r--browser/components/customizableui/test/browser_913972_currentset_overflow.js92
-rw-r--r--browser/components/customizableui/test/browser_914138_widget_API_overflowable_toolbar.js347
-rw-r--r--browser/components/customizableui/test/browser_918049_skipintoolbarset_dnd.js46
-rw-r--r--browser/components/customizableui/test/browser_923857_customize_mode_event_wrapping_during_reset.js27
-rw-r--r--browser/components/customizableui/test/browser_927717_customize_drag_empty_toolbar.js29
-rw-r--r--browser/components/customizableui/test/browser_934113_menubar_removable.js43
-rw-r--r--browser/components/customizableui/test/browser_934951_zoom_in_toolbar.js94
-rw-r--r--browser/components/customizableui/test/browser_938980_navbar_collapsed.js214
-rw-r--r--browser/components/customizableui/test/browser_938995_indefaultstate_nonremovable.js45
-rw-r--r--browser/components/customizableui/test/browser_940013_registerToolbarNode_calls_registerArea.js64
-rw-r--r--browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js141
-rw-r--r--browser/components/customizableui/test/browser_940946_removable_from_navbar_customizemode.js30
-rw-r--r--browser/components/customizableui/test/browser_941083_invalidate_wrapper_cache_createWidget.js49
-rw-r--r--browser/components/customizableui/test/browser_942581_unregisterArea_keeps_placements.js119
-rw-r--r--browser/components/customizableui/test/browser_944887_destroyWidget_should_destroy_in_palette.js26
-rw-r--r--browser/components/customizableui/test/browser_945739_showInPrivateBrowsing_customize_mode.js43
-rw-r--r--browser/components/customizableui/test/browser_947914_button_copy.js64
-rw-r--r--browser/components/customizableui/test/browser_947914_button_cut.js58
-rw-r--r--browser/components/customizableui/test/browser_947914_button_find.js37
-rw-r--r--browser/components/customizableui/test/browser_947914_button_history.js68
-rw-r--r--browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js62
-rw-r--r--browser/components/customizableui/test/browser_947914_button_newWindow.js62
-rw-r--r--browser/components/customizableui/test/browser_947914_button_paste.js55
-rw-r--r--browser/components/customizableui/test/browser_947914_button_print.js54
-rw-r--r--browser/components/customizableui/test/browser_947914_button_zoomIn.js60
-rw-r--r--browser/components/customizableui/test/browser_947914_button_zoomOut.js61
-rw-r--r--browser/components/customizableui/test/browser_947914_button_zoomReset.js75
-rw-r--r--browser/components/customizableui/test/browser_947987_removable_default.js94
-rw-r--r--browser/components/customizableui/test/browser_948985_non_removable_defaultArea.js47
-rw-r--r--browser/components/customizableui/test/browser_952963_areaType_getter_no_area.js70
-rw-r--r--browser/components/customizableui/test/browser_956602_remove_special_widget.js37
-rw-r--r--browser/components/customizableui/test/browser_962069_drag_to_overflow_chevron.js79
-rw-r--r--browser/components/customizableui/test/browser_963639_customizing_attribute_non_customizable_toolbar.js48
-rw-r--r--browser/components/customizableui/test/browser_968565_insert_before_hidden_items.js60
-rw-r--r--browser/components/customizableui/test/browser_969427_recreate_destroyed_widget_after_reset.js47
-rw-r--r--browser/components/customizableui/test/browser_969661_character_encoding_navbar_disabled.js28
-rw-r--r--browser/components/customizableui/test/browser_970511_undo_restore_default.js274
-rw-r--r--browser/components/customizableui/test/browser_972267_customizationchange_events.js39
-rw-r--r--browser/components/customizableui/test/browser_976792_insertNodeInWindow.js597
-rw-r--r--browser/components/customizableui/test/browser_978084_dragEnd_after_move.js52
-rw-r--r--browser/components/customizableui/test/browser_980155_add_overflow_toolbar.js97
-rw-r--r--browser/components/customizableui/test/browser_981305_separator_insertion.js89
-rw-r--r--browser/components/customizableui/test/browser_981418-widget-onbeforecreated-handler.js66
-rw-r--r--browser/components/customizableui/test/browser_982656_restore_defaults_builtin_widgets.js82
-rw-r--r--browser/components/customizableui/test/browser_984455_bookmarks_items_reparenting.js328
-rw-r--r--browser/components/customizableui/test/browser_985815_propagate_setToolbarVisibility.js56
-rw-r--r--browser/components/customizableui/test/browser_987177_destroyWidget_xul.js35
-rw-r--r--browser/components/customizableui/test/browser_987177_xul_wrapper_updating.js142
-rw-r--r--browser/components/customizableui/test/browser_987492_window_api.js83
-rw-r--r--browser/components/customizableui/test/browser_987640_charEncoding.js78
-rw-r--r--browser/components/customizableui/test/browser_989338_saved_placements_not_resaved.js70
-rw-r--r--browser/components/customizableui/test/browser_989751_subviewbutton_class.js91
-rw-r--r--browser/components/customizableui/test/browser_992747_toggle_noncustomizable_toolbar.js25
-rw-r--r--browser/components/customizableui/test/browser_993322_widget_notoolbar.js59
-rw-r--r--browser/components/customizableui/test/browser_995164_registerArea_during_customize_mode.js278
-rw-r--r--browser/components/customizableui/test/browser_996364_registerArea_different_properties.js142
-rw-r--r--browser/components/customizableui/test/browser_996635_remove_non_widgets.js54
-rw-r--r--browser/components/customizableui/test/browser_PanelMultiView.js566
-rw-r--r--browser/components/customizableui/test/browser_PanelMultiView_focus.js170
-rw-r--r--browser/components/customizableui/test/browser_PanelMultiView_keyboard.js583
-rw-r--r--browser/components/customizableui/test/browser_addons_area.js76
-rw-r--r--browser/components/customizableui/test/browser_allow_dragging_removable_false.js42
-rw-r--r--browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js79
-rw-r--r--browser/components/customizableui/test/browser_bookmarks_empty_message.js83
-rw-r--r--browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js35
-rw-r--r--browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js34
-rw-r--r--browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js81
-rw-r--r--browser/components/customizableui/test/browser_check_tooltips_in_navbar.js21
-rw-r--r--browser/components/customizableui/test/browser_create_button_widget.js90
-rw-r--r--browser/components/customizableui/test/browser_ctrl_click_panel_opening.js56
-rw-r--r--browser/components/customizableui/test/browser_currentset_post_reset.js37
-rw-r--r--browser/components/customizableui/test/browser_customization_context_menus.js632
-rw-r--r--browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js71
-rw-r--r--browser/components/customizableui/test/browser_customizemode_lwthemes.js25
-rw-r--r--browser/components/customizableui/test/browser_customizemode_uidensity.js230
-rw-r--r--browser/components/customizableui/test/browser_disable_commands_customize.js86
-rw-r--r--browser/components/customizableui/test/browser_drag_outside_palette.js53
-rw-r--r--browser/components/customizableui/test/browser_editcontrols_update.js307
-rw-r--r--browser/components/customizableui/test/browser_exit_background_customize_mode.js44
-rw-r--r--browser/components/customizableui/test/browser_flexible_space_area.js48
-rw-r--r--browser/components/customizableui/test/browser_help_panel_cloning.js90
-rw-r--r--browser/components/customizableui/test/browser_hidden_widget_overflow.js115
-rw-r--r--browser/components/customizableui/test/browser_history_after_appMenu.js35
-rw-r--r--browser/components/customizableui/test/browser_history_recently_closed.js430
-rw-r--r--browser/components/customizableui/test/browser_history_recently_closed_middleclick.js106
-rw-r--r--browser/components/customizableui/test/browser_history_restore_session.js52
-rw-r--r--browser/components/customizableui/test/browser_insert_before_moved_node.js51
-rw-r--r--browser/components/customizableui/test/browser_menubar_visibility.js66
-rw-r--r--browser/components/customizableui/test/browser_newtab_button_customizemode.js181
-rw-r--r--browser/components/customizableui/test/browser_open_from_popup.js24
-rw-r--r--browser/components/customizableui/test/browser_open_in_lazy_tab.js42
-rw-r--r--browser/components/customizableui/test/browser_overflow_use_subviews.js88
-rw-r--r--browser/components/customizableui/test/browser_palette_labels.js66
-rw-r--r--browser/components/customizableui/test/browser_panelUINotifications.js597
-rw-r--r--browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js151
-rw-r--r--browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js92
-rw-r--r--browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js145
-rw-r--r--browser/components/customizableui/test/browser_panelUINotifications_modals.js87
-rw-r--r--browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js214
-rw-r--r--browser/components/customizableui/test/browser_panel_keyboard_navigation.js326
-rw-r--r--browser/components/customizableui/test/browser_panel_locationSpecific.js78
-rw-r--r--browser/components/customizableui/test/browser_panel_menulist.js50
-rw-r--r--browser/components/customizableui/test/browser_panel_toggle.js53
-rw-r--r--browser/components/customizableui/test/browser_proton_moreTools_panel.js54
-rw-r--r--browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js285
-rw-r--r--browser/components/customizableui/test/browser_registerArea.js28
-rw-r--r--browser/components/customizableui/test/browser_reload_tab.js103
-rw-r--r--browser/components/customizableui/test/browser_remote_attribute.js73
-rw-r--r--browser/components/customizableui/test/browser_remote_tabs_button.js100
-rw-r--r--browser/components/customizableui/test/browser_remove_customized_specials.js35
-rw-r--r--browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js26
-rw-r--r--browser/components/customizableui/test/browser_reset_dom_events.js34
-rw-r--r--browser/components/customizableui/test/browser_screenshot_button_disabled.js22
-rw-r--r--browser/components/customizableui/test/browser_searchbar_removal.js36
-rw-r--r--browser/components/customizableui/test/browser_sidebar_toggle.js58
-rw-r--r--browser/components/customizableui/test/browser_switch_to_customize_mode.js53
-rw-r--r--browser/components/customizableui/test/browser_synced_tabs_menu.js523
-rw-r--r--browser/components/customizableui/test/browser_tabbar_big_widgets.js32
-rw-r--r--browser/components/customizableui/test/browser_toolbar_collapsed_states.js112
-rw-r--r--browser/components/customizableui/test/browser_touchbar_customization.js21
-rw-r--r--browser/components/customizableui/test/browser_unified_extensions_reset.js91
-rw-r--r--browser/components/customizableui/test/browser_widget_animation.js84
-rw-r--r--browser/components/customizableui/test/browser_widget_recreate_events.js99
-rw-r--r--browser/components/customizableui/test/dummy_history_item.html2
-rw-r--r--browser/components/customizableui/test/head.js530
-rw-r--r--browser/components/customizableui/test/support/test_967000_charEncoding_page.html11
-rw-r--r--browser/components/customizableui/test/unit/test_unified_extensions_migration.js373
-rw-r--r--browser/components/customizableui/test/unit/xpcshell.toml6
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"]