Skip to content

Commit 1111b29

Browse files
committed
feat: collapse overflowing app menu items into hamburger menu
When the titlebar is too narrow to fit all menu items on one row, overflow items are now hidden and a hamburger button (☰) appears. Hovering hamburger entries shows flyout submenus with full menu contents including nested sub-submenus. Also dismisses all menus, context menus and popups on window blur.
1 parent 633e612 commit 1111b29

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

src/command/Menus.js

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,13 +1762,288 @@ define(function (require, exports, module) {
17621762
return cmenu;
17631763
}
17641764

1765+
/**
1766+
* Hamburger menu: when the titlebar is too narrow to fit all menu items on one row,
1767+
* overflow items are hidden and a hamburger button appears with a dropdown listing them.
1768+
*/
1769+
function _initHamburgerMenu() {
1770+
const $menubar = $("#titlebar .nav");
1771+
const $hamburger = $(`<li class="hamburger-menu" id="hamburger-menu" style="display:none;">
1772+
<a href="#" class="hamburger-toggle">
1773+
<i class="fa-solid fa-bars"></i>
1774+
</a>
1775+
<ul class="dropdown-menu hamburger-dropdown"></ul>
1776+
</li>`);
1777+
$menubar.append($hamburger);
1778+
const $hamburgerDropdown = $hamburger.find(".dropdown-menu");
1779+
const $hamburgerToggle = $hamburger.find(".hamburger-toggle");
1780+
let _activeSubmenuId = null;
1781+
1782+
function _resetMenuItemStyles($menuItem) {
1783+
const menu = menuMap[$menuItem.attr("id")];
1784+
if (menu) {
1785+
menu.closeSubMenu();
1786+
}
1787+
$menuItem.removeClass("open").css({
1788+
display: "none",
1789+
position: "",
1790+
visibility: "",
1791+
pointerEvents: "",
1792+
width: "",
1793+
height: "",
1794+
overflow: ""
1795+
});
1796+
$menuItem.find("> .dropdown-menu").css({
1797+
display: "",
1798+
visibility: "",
1799+
pointerEvents: "",
1800+
position: "",
1801+
top: "",
1802+
left: "",
1803+
margin: ""
1804+
});
1805+
}
1806+
1807+
function _closeHamburgerSubmenus() {
1808+
$hamburgerDropdown.find(".hamburger-submenu-open").removeClass("hamburger-submenu-open");
1809+
// Reset the active flyout menu item
1810+
if (_activeSubmenuId) {
1811+
_resetMenuItemStyles($(`#${_activeSubmenuId}`));
1812+
_activeSubmenuId = null;
1813+
}
1814+
// Safety: also reset any overflow menu items that might still have
1815+
// inline styles from a flyout that wasn't properly closed
1816+
$menubar.children("li.dropdown:not(.hamburger-menu)").each(function () {
1817+
const $item = $(this);
1818+
if ($item.css("display") !== "none" && $item.find("> .dropdown-menu").css("position") === "fixed") {
1819+
_resetMenuItemStyles($item);
1820+
}
1821+
});
1822+
}
1823+
1824+
function _closeHamburger() {
1825+
$hamburger.removeClass("hamburger-open");
1826+
_closeHamburgerSubmenus();
1827+
}
1828+
1829+
// Wire up hamburger click to toggle the dropdown
1830+
$hamburgerToggle.on("click", function (e) {
1831+
e.preventDefault();
1832+
e.stopPropagation();
1833+
const wasOpen = $hamburger.hasClass("hamburger-open");
1834+
closeAll();
1835+
_closeHamburger();
1836+
if (!wasOpen) {
1837+
$hamburger.addClass("hamburger-open");
1838+
}
1839+
});
1840+
1841+
// Close hamburger when clicking outside
1842+
$(document).on("mousedown", function (e) {
1843+
if (!$hamburger.hasClass("hamburger-open")) {
1844+
return;
1845+
}
1846+
// Check if click is inside hamburger
1847+
if ($(e.target).closest("#hamburger-menu").length) {
1848+
return;
1849+
}
1850+
// Check if click is inside the active flyout menu
1851+
if (_activeSubmenuId && $(e.target).closest(`#${_activeSubmenuId}`).length) {
1852+
return;
1853+
}
1854+
// Check if click is inside any open context menu (sub-submenus
1855+
// live in #context-menu-bar, not inside the flyout menu)
1856+
if ($(e.target).closest("#context-menu-bar .open").length) {
1857+
return;
1858+
}
1859+
_closeHamburger();
1860+
});
1861+
1862+
// Wire up hamburger toggle mouseenter like other menus
1863+
$hamburgerToggle.on("mouseenter", function () {
1864+
_closeAllSubMenus();
1865+
const $this = $(this);
1866+
if ($('#titlebar, #titlebar *').is(':focus')) {
1867+
$this.addClass('selected').focus();
1868+
} else {
1869+
$this.addClass('selected');
1870+
}
1871+
});
1872+
$hamburgerToggle.on("mouseleave", function () {
1873+
$(this).removeClass('selected');
1874+
});
1875+
1876+
// Close hamburger when ESC is pressed
1877+
$(document).on("keydown", function (e) {
1878+
if (e.key === "Escape" && $hamburger.hasClass("hamburger-open")) {
1879+
_closeHamburger();
1880+
e.stopPropagation();
1881+
}
1882+
});
1883+
1884+
// Close hamburger when window loses focus
1885+
$(window).on("blur", _closeHamburger);
1886+
1887+
// Close hamburger when a menu item in a flyout is clicked.
1888+
// Use setTimeout so the command executes before we hide the menu.
1889+
$menubar.on("click", ".dropdown:not(.hamburger-menu) .menuAnchor", function () {
1890+
setTimeout(_closeHamburger, 0);
1891+
});
1892+
1893+
// Also close on beforeExecuteCommand (e.g. keyboard shortcuts while open)
1894+
CommandManager.on("beforeExecuteCommand", function () {
1895+
_closeHamburger();
1896+
});
1897+
1898+
let _updateScheduled = false;
1899+
1900+
function _updateHamburgerMenu() {
1901+
_updateScheduled = false;
1902+
// Don't re-layout while a flyout submenu is active - showing the
1903+
// hidden menu li triggers ResizeObserver which would reset everything
1904+
if (_activeSubmenuId) {
1905+
return;
1906+
}
1907+
_closeHamburgerSubmenus();
1908+
const $items = $menubar.children("li.dropdown:not(.hamburger-menu)");
1909+
// First, show all items and hide hamburger to measure natural layout
1910+
$items.css({display: "", position: "", visibility: "", pointerEvents: ""});
1911+
$hamburger.hide();
1912+
$hamburgerDropdown.empty();
1913+
1914+
if ($items.length === 0) {
1915+
return;
1916+
}
1917+
1918+
const firstItemTop = $items.first()[0].offsetTop;
1919+
let overflowStartIndex = -1;
1920+
1921+
for (let i = 0; i < $items.length; i++) {
1922+
if ($items[i].offsetTop > firstItemTop) {
1923+
overflowStartIndex = i;
1924+
break;
1925+
}
1926+
}
1927+
1928+
if (overflowStartIndex === -1) {
1929+
// Everything fits on one row
1930+
return;
1931+
}
1932+
1933+
// Show hamburger, then re-check what fits with hamburger visible
1934+
$hamburger.css("display", "");
1935+
1936+
// Re-measure: with hamburger visible, even more items might overflow
1937+
for (let i = 0; i < $items.length; i++) {
1938+
if ($items[i].offsetTop > firstItemTop) {
1939+
overflowStartIndex = i;
1940+
break;
1941+
}
1942+
}
1943+
1944+
function _openFlyout($entry, menuId) {
1945+
if (_activeSubmenuId && _activeSubmenuId !== menuId) {
1946+
_closeHamburgerSubmenus();
1947+
}
1948+
$hamburgerDropdown.find(".hamburger-submenu-open").removeClass("hamburger-submenu-open");
1949+
$entry.addClass("hamburger-submenu-open");
1950+
_activeSubmenuId = menuId;
1951+
1952+
const $menuItem = $(`#${menuId}`);
1953+
// Add 'open' class so sub-submenus (ContextMenus) can open properly.
1954+
// Keep the li itself invisible and out of flow.
1955+
$menuItem.addClass("open").css({
1956+
display: "block",
1957+
position: "absolute",
1958+
visibility: "hidden",
1959+
pointerEvents: "none",
1960+
width: "0",
1961+
height: "0",
1962+
overflow: "visible"
1963+
});
1964+
1965+
const $realDropdown = $menuItem.find("> .dropdown-menu");
1966+
const entryRect = $entry[0].getBoundingClientRect();
1967+
const hamburgerRect = $hamburgerDropdown[0].getBoundingClientRect();
1968+
let flyoutLeft = hamburgerRect.right - 2;
1969+
if (flyoutLeft + 250 > window.innerWidth) {
1970+
flyoutLeft = hamburgerRect.left - $realDropdown.outerWidth() + 2;
1971+
}
1972+
$realDropdown.css({
1973+
display: "block",
1974+
visibility: "visible",
1975+
pointerEvents: "auto",
1976+
position: "fixed",
1977+
top: entryRect.top + "px",
1978+
left: flyoutLeft + "px",
1979+
margin: "0"
1980+
});
1981+
}
1982+
1983+
// Hide overflowing items and add them to hamburger dropdown as nested flyouts
1984+
for (let i = overflowStartIndex; i < $items.length; i++) {
1985+
const $item = $($items[i]);
1986+
const menuId = $item.attr("id");
1987+
const menuName = $item.find(".dropdown-toggle").text();
1988+
$item.css("display", "none");
1989+
1990+
const $entry = $(`<li class="hamburger-submenu-item">
1991+
<a href="#" class="menuAnchor" data-menu-id="${menuId}">
1992+
<span class="menu-name">${_.escape(menuName)}</span>
1993+
<span class="hamburger-submenu-arrow">&#9656;</span>
1994+
</a>
1995+
</li>`);
1996+
1997+
$entry.on("mouseenter", function () {
1998+
_openFlyout($(this), menuId);
1999+
});
2000+
2001+
$hamburgerDropdown.append($entry);
2002+
}
2003+
}
2004+
2005+
function _scheduleUpdate() {
2006+
if (!_updateScheduled) {
2007+
_updateScheduled = true;
2008+
requestAnimationFrame(_updateHamburgerMenu);
2009+
}
2010+
}
2011+
2012+
// Observe titlebar resizes
2013+
const titlebar = document.getElementById("titlebar");
2014+
if (window.ResizeObserver) {
2015+
const resizeObserver = new ResizeObserver(_scheduleUpdate);
2016+
resizeObserver.observe(titlebar);
2017+
}
2018+
$(window).on("resize", _scheduleUpdate);
2019+
2020+
// Also update when menus are added/removed
2021+
exports.on(EVENT_MENU_ADDED, _scheduleUpdate);
2022+
2023+
// Initial check
2024+
_scheduleUpdate();
2025+
}
2026+
17652027
AppInit.htmlReady(function () {
17662028
$('#titlebar').on('focusin', function () {
17672029
KeyBindingManager.addGlobalKeydownHook(menuKeyboardNavigationHandler);
17682030
});
17692031
$('#titlebar').on('focusout', function () {
17702032
KeyBindingManager.removeGlobalKeydownHook(menuKeyboardNavigationHandler);
17712033
});
2034+
_initHamburgerMenu();
2035+
2036+
// Close all menus, context menus, and popups when window loses focus
2037+
$(window).on("blur", function () {
2038+
closeAll();
2039+
// Close all context menus (editor, file tree, working set, etc.)
2040+
_.forEach(contextMenuMap, function (contextMenu) {
2041+
if (contextMenu.isOpen()) {
2042+
contextMenu.close();
2043+
}
2044+
});
2045+
PopUpManager.closeAllPopups();
2046+
});
17722047
});
17732048

17742049
EventDispatcher.makeEventDispatcher(exports);

src/styles/brackets_patterns_override.less

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,64 @@ a:focus {
211211
animation: none;
212212
}
213213
}
214+
.nav .hamburger-menu {
215+
float: left;
216+
.hamburger-toggle, .hamburger-toggle:hover, .hamburger-toggle:focus {
217+
padding: @menubar-top-padding @menubar-h-padding @menubar-bottom-padding;
218+
border: 1px solid transparent;
219+
font-size: 14px;
220+
color: fadeout(@bc-menu-text, 25%);
221+
background: transparent;
222+
cursor: default;
223+
outline: none;
224+
.dark & {
225+
color: fadeout(@dark-bc-menu-text, 25%);
226+
background: transparent;
227+
}
228+
}
229+
.hamburger-toggle:hover, &.hamburger-open .hamburger-toggle {
230+
color: @bc-menu-text;
231+
background: @bc-bg-highlight;
232+
.dark & {
233+
color: @dark-bc-menu-text;
234+
background: @dark-bc-bg-highlight;
235+
}
236+
}
237+
.hamburger-dropdown {
238+
display: none;
239+
min-width: 120px;
240+
text-align: left;
241+
}
242+
&.hamburger-open .hamburger-dropdown {
243+
display: block;
244+
}
245+
.hamburger-submenu-item {
246+
a {
247+
display: flex;
248+
align-items: center;
249+
padding: 2px 10px 0 6px;
250+
white-space: nowrap;
251+
cursor: default;
252+
text-align: left;
253+
}
254+
.hamburger-submenu-arrow {
255+
margin-left: auto;
256+
padding-left: 12px;
257+
font-size: 10px;
258+
opacity: 0.7;
259+
}
260+
&.hamburger-submenu-open > a,
261+
a:hover {
262+
background: @bc-bg-highlight;
263+
color: @bc-menu-text;
264+
.dark & {
265+
background: @dark-bc-bg-highlight;
266+
color: @dark-bc-menu-text;
267+
}
268+
}
269+
}
270+
}
271+
214272
.title {
215273
float: none;
216274
display: inline; // must be an inline for JS to measure text size

0 commit comments

Comments
 (0)