diff --git a/src/command/Menus.js b/src/command/Menus.js index 7c7151e814..18fb38ea21 100644 --- a/src/command/Menus.js +++ b/src/command/Menus.js @@ -1369,10 +1369,9 @@ define(function (require, exports, module) { $menuDropdownToggle.parent().removeClass('open'); const menuID = $menuDropdownToggle.parent().get(0).id; const mainMenu = menuMap[menuID]; - const $dropdownToggles = $('#titlebar .dropdown-toggle'); + const $dropdownToggles = $('#titlebar .dropdown:not(.hamburger-menu):visible > .dropdown-toggle'); let currentIndex = $dropdownToggles.index($menuDropdownToggle); - currentIndex = event.key === KEY.ARROW_LEFT ? currentIndex - 1 : currentIndex + 1; - let nextIndex = currentIndex; + let nextIndex = event.key === KEY.ARROW_LEFT ? currentIndex - 1 : currentIndex + 1; if (nextIndex < 0) { nextIndex = 0; } else if (nextIndex >= $dropdownToggles.length) { @@ -1762,6 +1761,323 @@ define(function (require, exports, module) { return cmenu; } + /** + * Hamburger menu: when the titlebar is too narrow to fit all menu items on one row, + * overflow items are hidden and a hamburger button appears with a dropdown listing them. + */ + function _initHamburgerMenu() { + const $menubar = $("#titlebar .nav"); + const $hamburger = $(``); + $menubar.append($hamburger); + const $hamburgerDropdown = $hamburger.find(".dropdown-menu"); + const $hamburgerToggle = $hamburger.find(".hamburger-toggle"); + let _activeSubmenuId = null; + + // Sidebar collapse/expand toggle button before the File menu + const $sidebarToggle = $(``); + $menubar.prepend($sidebarToggle); + const $sidebarIcon = $sidebarToggle.find("a"); + + function _updateSidebarToggleIcon() { + const isVisible = $("#sidebar").is(":visible"); + if (isVisible) { + $sidebarIcon.html(''); + $sidebarIcon.attr("title", Strings.CMD_HIDE_SIDEBAR); + } else { + $sidebarIcon.html(''); + $sidebarIcon.attr("title", Strings.CMD_SHOW_SIDEBAR); + } + } + + $sidebarIcon.on("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + CommandManager.execute(Commands.VIEW_HIDE_SIDEBAR); + }); + + $("#sidebar").on("panelCollapsed panelExpanded", _updateSidebarToggleIcon); + _updateSidebarToggleIcon(); + + function _resetMenuItemStyles($menuItem) { + const menu = menuMap[$menuItem.attr("id")]; + if (menu) { + menu.closeSubMenu(); + } + $menuItem.removeClass("open").css({ + display: "none", + position: "", + visibility: "", + pointerEvents: "", + width: "", + height: "", + overflow: "" + }); + $menuItem.find("> .dropdown-menu").css({ + display: "", + visibility: "", + pointerEvents: "", + position: "", + top: "", + left: "", + margin: "" + }); + } + + function _closeHamburgerSubmenus() { + $hamburgerDropdown.find(".hamburger-submenu-open").removeClass("hamburger-submenu-open"); + // Reset the active flyout menu item + if (_activeSubmenuId) { + _resetMenuItemStyles($(`#${_activeSubmenuId}`)); + _activeSubmenuId = null; + } + // Safety: also reset any overflow menu items that might still have + // inline styles from a flyout that wasn't properly closed + $menubar.children("li.dropdown:not(.hamburger-menu)").each(function () { + const $item = $(this); + if ($item.css("display") !== "none" && $item.find("> .dropdown-menu").css("position") === "fixed") { + _resetMenuItemStyles($item); + } + }); + } + + function _closeHamburger() { + $hamburger.removeClass("hamburger-open"); + _closeHamburgerSubmenus(); + } + + // Wire up hamburger click to toggle the dropdown + $hamburgerToggle.on("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + const wasOpen = $hamburger.hasClass("hamburger-open"); + closeAll(); + _closeHamburger(); + if (!wasOpen) { + $hamburger.addClass("hamburger-open"); + } + }); + + // Close hamburger when clicking outside + $(document).on("mousedown", function (e) { + if (!$hamburger.hasClass("hamburger-open")) { + return; + } + // Check if click is inside hamburger + if ($(e.target).closest("#hamburger-menu").length) { + return; + } + // Check if click is inside the active flyout menu + if (_activeSubmenuId && $(e.target).closest(`#${_activeSubmenuId}`).length) { + return; + } + // Check if click is inside any open context menu (sub-submenus + // live in #context-menu-bar, not inside the flyout menu) + if ($(e.target).closest("#context-menu-bar .open").length) { + return; + } + _closeHamburger(); + }); + + // Close hamburger and open the hovered normal menu + $menubar.on("mouseenter", ".dropdown:not(.hamburger-menu) > .dropdown-toggle", function () { + if ($hamburger.hasClass("hamburger-open")) { + _closeHamburger(); + // Open the hovered menu and focus its toggle so keyboard nav works + const $toggle = $(this); + $toggle.parent().addClass("open"); + $toggle.focus(); + } + }); + + // Wire up hamburger toggle mouseenter like other menus. + // If the titlebar has focus (meaning a menu is already open), + // auto-open the hamburger on hover - matching normal menu behavior. + $hamburgerToggle.on("mouseenter", function () { + _closeAllSubMenus(); + const $this = $(this); + if ($('#titlebar, #titlebar *').is(':focus')) { + // Close any open normal menus first + closeAll(); + $this.addClass('selected').focus(); + if (!$hamburger.hasClass("hamburger-open")) { + $hamburger.addClass("hamburger-open"); + } + } else { + $this.addClass('selected'); + } + }); + $hamburgerToggle.on("mouseleave", function () { + $(this).removeClass('selected'); + }); + + // Close hamburger when ESC is pressed + $(document).on("keydown", function (e) { + if (e.key === "Escape" && $hamburger.hasClass("hamburger-open")) { + _closeHamburger(); + e.stopPropagation(); + } + }); + + // Close hamburger when window loses focus + $(window).on("blur", _closeHamburger); + + // Close hamburger when a menu item in a flyout is clicked. + // Use setTimeout so the command executes before we hide the menu. + $menubar.on("click", ".dropdown:not(.hamburger-menu) .menuAnchor", function () { + setTimeout(_closeHamburger, 0); + }); + + // Also close on beforeExecuteCommand (e.g. keyboard shortcuts while open) + CommandManager.on("beforeExecuteCommand", function () { + _closeHamburger(); + }); + + let _updateScheduled = false; + + function _updateHamburgerMenu() { + _updateScheduled = false; + // Don't re-layout while a flyout submenu is active - showing the + // hidden menu li triggers ResizeObserver which would reset everything + if (_activeSubmenuId) { + return; + } + _closeHamburgerSubmenus(); + const $items = $menubar.children("li.dropdown:not(.hamburger-menu)"); + // First, show all items and hide hamburger to measure natural layout + $items.css({display: "", position: "", visibility: "", pointerEvents: ""}); + // Ensure hamburger is always the last item in the menu bar + $menubar.append($hamburger); + $hamburger.hide(); + $hamburgerDropdown.empty(); + + if ($items.length === 0) { + return; + } + + const firstItemTop = $items.first()[0].offsetTop; + let overflowStartIndex = -1; + + for (let i = 0; i < $items.length; i++) { + if ($items[i].offsetTop > firstItemTop) { + overflowStartIndex = i; + break; + } + } + + if (overflowStartIndex === -1) { + // Everything fits on one row + return; + } + + // Show hamburger, then keep hiding items from the end until + // both visible items and the hamburger fit on one row + $hamburger.css("display", ""); + + while (overflowStartIndex > 0 && $hamburger[0].offsetTop > firstItemTop) { + overflowStartIndex--; + $($items[overflowStartIndex]).css("display", "none"); + } + + // Also check if any remaining items wrapped + for (let i = 0; i < overflowStartIndex; i++) { + if ($items[i].offsetTop > firstItemTop) { + overflowStartIndex = i; + break; + } + } + + function _openFlyout($entry, menuId) { + if (_activeSubmenuId && _activeSubmenuId !== menuId) { + _closeHamburgerSubmenus(); + } + $hamburgerDropdown.find(".hamburger-submenu-open").removeClass("hamburger-submenu-open"); + $entry.addClass("hamburger-submenu-open"); + _activeSubmenuId = menuId; + + const $menuItem = $(`#${menuId}`); + // Add 'open' class so sub-submenus (ContextMenus) can open properly. + // Keep the li itself invisible and out of flow. + $menuItem.addClass("open").css({ + display: "block", + position: "absolute", + visibility: "hidden", + pointerEvents: "none", + width: "0", + height: "0", + overflow: "visible" + }); + + const $realDropdown = $menuItem.find("> .dropdown-menu"); + const entryRect = $entry[0].getBoundingClientRect(); + const hamburgerRect = $hamburgerDropdown[0].getBoundingClientRect(); + let flyoutLeft = hamburgerRect.right - 2; + if (flyoutLeft + 250 > window.innerWidth) { + flyoutLeft = hamburgerRect.left - $realDropdown.outerWidth() + 2; + } + $realDropdown.css({ + display: "block", + visibility: "visible", + pointerEvents: "auto", + position: "fixed", + top: entryRect.top + "px", + left: flyoutLeft + "px", + margin: "0" + }); + } + + // Hide overflowing items and add them to hamburger dropdown as nested flyouts + for (let i = overflowStartIndex; i < $items.length; i++) { + const $item = $($items[i]); + const menuId = $item.attr("id"); + const menuName = $item.find(".dropdown-toggle").text(); + $item.css("display", "none"); + + const $entry = $(`
  • + + ${_.escape(menuName)} + + +
  • `); + + $entry.on("mouseenter", function () { + _openFlyout($(this), menuId); + }); + + $hamburgerDropdown.append($entry); + } + } + + function _scheduleUpdate() { + if (!_updateScheduled) { + _updateScheduled = true; + requestAnimationFrame(_updateHamburgerMenu); + } + } + + // Observe titlebar resizes + const titlebar = document.getElementById("titlebar"); + if (window.ResizeObserver) { + const resizeObserver = new ResizeObserver(_scheduleUpdate); + resizeObserver.observe(titlebar); + } + $(window).on("resize", _scheduleUpdate); + + // Also update when menus are added/removed + exports.on(EVENT_MENU_ADDED, _scheduleUpdate); + + // Initial check + _scheduleUpdate(); + } + AppInit.htmlReady(function () { $('#titlebar').on('focusin', function () { KeyBindingManager.addGlobalKeydownHook(menuKeyboardNavigationHandler); @@ -1769,6 +2085,19 @@ define(function (require, exports, module) { $('#titlebar').on('focusout', function () { KeyBindingManager.removeGlobalKeydownHook(menuKeyboardNavigationHandler); }); + _initHamburgerMenu(); + + // Close all menus, context menus, and popups when window loses focus + $(window).on("blur", function () { + closeAll(); + // Close all context menus (editor, file tree, working set, etc.) + _.forEach(contextMenuMap, function (contextMenu) { + if (contextMenu.isOpen()) { + contextMenu.close(); + } + }); + PopUpManager.closeAllPopups(); + }); }); EventDispatcher.makeEventDispatcher(exports); diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index 5e6085c579..b1f19e0a26 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -211,6 +211,89 @@ a:focus { animation: none; } } + .nav .sidebar-toggle-btn { + float: left; + a { + display: inline-block; + padding: @menubar-top-padding 6px @menubar-bottom-padding; + border: 1px solid transparent; + font-size: @menu-item-font-size; + color: fadeout(@bc-menu-text, 50%); + cursor: default; + outline: none; + .dark & { + color: fadeout(@dark-bc-menu-text, 50%); + } + &:hover { + color: @bc-menu-text; + .dark & { + color: @dark-bc-menu-text; + } + } + } + } + + .nav .hamburger-menu { + float: left; + .hamburger-toggle, .hamburger-toggle:hover, .hamburger-toggle:focus { + padding: @menubar-top-padding @menubar-h-padding @menubar-bottom-padding; + border: 1px solid transparent; + font-size: 14px; + color: fadeout(@bc-menu-text, 25%); + background: transparent; + cursor: default; + outline: none; + .dark & { + color: fadeout(@dark-bc-menu-text, 25%); + background: transparent; + } + } + .hamburger-toggle:hover, &.hamburger-open .hamburger-toggle { + color: @bc-menu-text; + background: @bc-bg-highlight; + .dark & { + color: @dark-bc-menu-text; + background: @dark-bc-bg-highlight; + } + } + position: relative; + .hamburger-dropdown { + display: none; + min-width: 120px; + text-align: left; + right: 0; + left: auto; + } + &.hamburger-open .hamburger-dropdown { + display: block; + } + .hamburger-submenu-item { + a { + display: flex; + align-items: center; + padding: 2px 10px 0 6px; + white-space: nowrap; + cursor: default; + text-align: left; + } + .hamburger-submenu-arrow { + margin-left: auto; + padding-left: 12px; + font-size: 10px; + opacity: 0.7; + } + &.hamburger-submenu-open > a, + a:hover { + background: @bc-bg-highlight; + color: @bc-menu-text; + .dark & { + background: @dark-bc-bg-highlight; + color: @dark-bc-menu-text; + } + } + } + } + .title { float: none; display: inline; // must be an inline for JS to measure text size diff --git a/src/widgets/Dialogs.js b/src/widgets/Dialogs.js index 1eb8271519..82dc1beb8e 100644 --- a/src/widgets/Dialogs.js +++ b/src/widgets/Dialogs.js @@ -412,13 +412,14 @@ define(function (require, exports, module) { autoDismiss = true; } - $("body").append(""); + let $wrapper = $(""); + $("body").append($wrapper); let result = new $.Deferred(), promise = result.promise(), $dlg = $(template) .addClass("instance") - .appendTo(".modal-inner-wrapper:last"); + .appendTo($wrapper.find(".modal-inner-wrapper")); // Don't allow dialog to exceed viewport size setDialogMaxSize(); @@ -463,7 +464,7 @@ define(function (require, exports, module) { } //Remove wrapper - $(".modal-wrapper:last").remove(); + $wrapper.remove(); }).one("shown", function () { let $defaultOption = $dlg.find(".default-option"), $primaryBtn = $dlg.find(".primary:enabled"), diff --git a/test/spec/Menu-integ-test.js b/test/spec/Menu-integ-test.js index fdd5140909..6e8454c9da 100644 --- a/test/spec/Menu-integ-test.js +++ b/test/spec/Menu-integ-test.js @@ -19,7 +19,7 @@ * */ -/*global describe, it, expect, beforeAll, afterAll*/ +/*global describe, it, expect, beforeAll, afterAll, afterEach*/ define(function (require, exports, module) { @@ -1151,5 +1151,272 @@ define(function (require, exports, module) { expect(element.getElementsByClassName("forced-hidden").length).toBe(0); }); }); + + describe("Hamburger Menu", function () { + + function getHamburger() { + return testWindow.$("#hamburger-menu"); + } + + function getHamburgerDropdown() { + return testWindow.$("#hamburger-menu .hamburger-dropdown"); + } + + function getHiddenMenuItems() { + return testWindow.$("#titlebar .nav > li.dropdown:not(.hamburger-menu)").filter(function () { + return testWindow.$(this).css("display") === "none"; + }); + } + + function forceNarrowTitlebar() { + // Shrink the .content container so the titlebar becomes narrow + const $content = testWindow.$(".content"); + $content.css({"right": "", "width": "200px"}); + $content[0].offsetHeight; + } + + function restoreTitlebar() { + const $content = testWindow.$(".content"); + $content.css({"left": "", "right": "", "width": ""}); + $content[0].offsetHeight; + } + + function awaitsForCondition(conditionFn, message, timeout) { + timeout = timeout || 5000; + return new Promise(function (resolve, reject) { + const startTime = Date.now(); + function check() { + if (conditionFn()) { + resolve(); + } else if (Date.now() - startTime > timeout) { + reject(new Error("Timed out waiting for: " + (message || "condition"))); + } else { + testWindow.requestAnimationFrame(check); + } + } + check(); + }); + } + + afterEach(function () { + // Clean up: close hamburger properly (clears _activeSubmenuId) + const $hamburger = getHamburger(); + if ($hamburger.hasClass("hamburger-open")) { + $hamburger.find(".hamburger-toggle").click(); + } + Menus.closeAll(); + restoreTitlebar(); + }); + + it("should exist in the DOM", function () { + const $hamburger = getHamburger(); + expect($hamburger.length).toBe(1); + expect($hamburger.find(".hamburger-toggle").length).toBe(1); + expect($hamburger.find(".hamburger-dropdown").length).toBe(1); + }); + + it("should be hidden when all menus fit on one row", async function () { + // The test window may have extra test menus from prior tests. + // Verify with a very wide container that hamburger hides. + const $content = testWindow.$(".content"); + const $main = $content.parent(); + $main.css("width", "4000px"); + $content.css({"left": "0", "right": "0", "position": "relative", "width": "4000px"}); + $content[0].offsetHeight; + await awaitsForCondition(function () { + return getHamburger().is(":hidden"); + }, "hamburger to be hidden"); + $main.css("width", ""); + $content.css({"left": "", "right": "", "position": "", "width": ""}); + }); + + it("should appear when titlebar is too narrow", async function () { + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + const $hidden = getHiddenMenuItems(); + expect($hidden.length).toBeGreaterThan(0); + const $entries = getHamburgerDropdown().find(".hamburger-submenu-item"); + expect($entries.length).toBe($hidden.length); + }); + + it("should open dropdown on click", async function () { + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + const $hamburger = getHamburger(); + expect($hamburger.hasClass("hamburger-open")).toBe(false); + + $hamburger.find(".hamburger-toggle").click(); + expect($hamburger.hasClass("hamburger-open")).toBe(true); + + const $dropdown = getHamburgerDropdown(); + expect($dropdown.css("display")).toBe("block"); + }); + + it("should close dropdown on second click", async function () { + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + const $hamburger = getHamburger(); + const $toggle = $hamburger.find(".hamburger-toggle"); + + $toggle.click(); + expect($hamburger.hasClass("hamburger-open")).toBe(true); + + $toggle.click(); + expect($hamburger.hasClass("hamburger-open")).toBe(false); + }); + + it("should open flyout submenu on entry hover", async function () { + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + const $hamburger = getHamburger(); + $hamburger.find(".hamburger-toggle").click(); + + const $entries = getHamburgerDropdown().find(".hamburger-submenu-item"); + expect($entries.length).toBeGreaterThan(0); + + const $firstEntry = $entries.first(); + $firstEntry.trigger("mouseenter"); + + expect($firstEntry.hasClass("hamburger-submenu-open")).toBe(true); + + const menuId = $firstEntry.find("a").attr("data-menu-id"); + const $menuDropdown = testWindow.$("#" + menuId + " > .dropdown-menu"); + expect($menuDropdown.css("visibility")).toBe("visible"); + expect($menuDropdown.css("position")).toBe("fixed"); + }); + + it("should restore all menus when titlebar expands", async function () { + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + expect(getHiddenMenuItems().length).toBeGreaterThan(0); + + // Expand wide enough for all menus + restoreTitlebar(); + const $content = testWindow.$(".content"); + const $main = $content.parent(); + $main.css("width", "4000px"); + $content.css({"left": "0", "right": "0", "position": "relative", "width": "4000px"}); + $content[0].offsetHeight; + await awaitsForCondition(function () { + return getHamburger().is(":hidden"); + }, "hamburger to hide after expanding"); + expect(getHiddenMenuItems().length).toBe(0); + $main.css("width", ""); + $content.css({"left": "", "right": "", "position": "", "width": ""}); + }); + + it("should execute command when clicking a flyout menu item", async function () { + // Register a test command and add it to a menu + let commandExecuted = false; + CommandManager.register("Hamburger Test Command", "Menu-test.hamburgerCmd1", function () { + commandExecuted = true; + }); + const fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU); + fileMenu.addMenuItem("Menu-test.hamburgerCmd1"); + + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + + // Open hamburger and hover File menu entry + const $hamburger = getHamburger(); + $hamburger.find(".hamburger-toggle").click(); + const $entries = getHamburgerDropdown().find(".hamburger-submenu-item"); + // Find the File menu entry + const $fileEntry = $entries.filter(function () { + return testWindow.$(this).find("a").attr("data-menu-id") === "file-menu"; + }); + expect($fileEntry.length).toBe(1); + $fileEntry.trigger("mouseenter"); + + // Click the test command in the flyout + const $menuItem = testWindow.$("#file-menu-Menu-test\\.hamburgerCmd1"); + expect($menuItem.length).toBe(1); + $menuItem.click(); + + await awaitsForCondition(function () { + return commandExecuted; + }, "command to be executed"); + await awaitsForCondition(function () { + return !$hamburger.hasClass("hamburger-open"); + }, "hamburger to close after command"); + }); + + it("should execute command when clicking a flyout submenu item", async function () { + // Register a command, create a submenu in the File menu + let subCommandExecuted = false; + CommandManager.register("Hamburger SubMenu Test", "Menu-test.hamburgerSubCmd1", function () { + subCommandExecuted = true; + }); + const fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU); + const subMenu = fileMenu.addSubMenu("Hamburger Sub", "hamburger-test-submenu1"); + subMenu.addMenuItem("Menu-test.hamburgerSubCmd1"); + + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + + // Open hamburger and hover File menu entry + const $hamburger = getHamburger(); + $hamburger.find(".hamburger-toggle").click(); + const $entries = getHamburgerDropdown().find(".hamburger-submenu-item"); + const $fileEntry = $entries.filter(function () { + return testWindow.$(this).find("a").attr("data-menu-id") === "file-menu"; + }); + $fileEntry.trigger("mouseenter"); + + // Hover the submenu trigger to open it + const $subMenuTrigger = testWindow.$("#file-menu-hamburger-test-submenu1").closest("li"); + expect($subMenuTrigger.length).toBe(1); + $subMenuTrigger.trigger("mouseenter"); + + // Wait for submenu to open + await awaitsForCondition(function () { + return testWindow.$("#hamburger-test-submenu1").hasClass("open"); + }, "submenu to open"); + + // Click the command in the submenu + const $subItem = testWindow.$("#hamburger-test-submenu1-Menu-test\\.hamburgerSubCmd1"); + expect($subItem.length).toBe(1); + $subItem.closest("li").click(); + + await awaitsForCondition(function () { + return subCommandExecuted; + }, "submenu command to be executed"); + }); + + it("should close on ESC key", async function () { + forceNarrowTitlebar(); + await awaitsForCondition(function () { + return !getHamburger().is(":hidden"); + }, "hamburger to appear"); + const $hamburger = getHamburger(); + $hamburger.find(".hamburger-toggle").click(); + expect($hamburger.hasClass("hamburger-open")).toBe(true); + + // Dispatch ESC keydown event + const escEvent = new testWindow.KeyboardEvent("keydown", { + key: "Escape", + keyCode: KeyEvent.DOM_VK_ESCAPE, + bubbles: true, + cancelable: true + }); + testWindow.document.dispatchEvent(escEvent); + + expect($hamburger.hasClass("hamburger-open")).toBe(false); + }); + }); }); });