diff --git a/app/cypress/e2e/menu.spec.js b/app/cypress/e2e/menu.spec.js index ddad2c7..080d0d8 100644 --- a/app/cypress/e2e/menu.spec.js +++ b/app/cypress/e2e/menu.spec.js @@ -1,4 +1,69 @@ -// cypress/e2e/dynamic_popup.spec.js +// cypress/e2e/menu.spec.js + +describe('Navigation dropdowns', () => { + beforeEach(() => { + cy.viewport(1280, 720); + cy.visit('/'); + }); + + it('opens top-level dropdowns with explicit Bootstrap instances', () => { + cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle') + .first() + .as('toggle') + .should('have.attr', 'data-bs-toggle', 'dropdown') + .and('have.attr', 'aria-expanded', 'false'); + + cy.get('@toggle').then($toggle => { + cy.window().then(win => { + expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist; + }); + }); + + cy.get('@toggle') + .parent('.nav-item') + .find('> .dropdown-menu') + .as('menu') + .should('not.have.class', 'show') + .should('not.be.visible'); + + cy.get('@toggle').click(); + cy.get('@toggle').should('have.attr', 'aria-expanded', 'true'); + cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown'); + cy.get('@menu') + .should('have.class', 'show') + .and('be.visible'); + }); + + it('flips footer dropdowns upward where there is more space above', () => { + cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle') + .first() + .as('toggle'); + + cy.get('@toggle') + .parent('.nav-item') + .as('item') + .find('> .dropdown-menu') + .as('menu') + .should('not.have.class', 'show'); + + // Scroll the page so the footer sits at the bottom of the viewport, + // then click without Cypress re-scrolling — otherwise the toggle could + // land near the top of the viewport and chooseDirection would keep + // .dropdown (more space below than above). + cy.scrollTo('bottom'); + cy.get('@toggle').then($toggle => { + const rect = $toggle[0].getBoundingClientRect(); + expect(rect.top, 'toggle is in the lower half of the viewport') + .to.be.greaterThan(Cypress.config('viewportHeight') / 2); + }); + cy.get('@toggle').click({ scrollBehavior: false }); + cy.get('@item').should('have.class', 'dropup'); + cy.get('@item').should('not.have.class', 'dropdown'); + cy.get('@menu') + .should('have.class', 'show') + .and('be.visible'); + }); +}); describe('Dynamic Popup', () => { const base = { diff --git a/app/static/css/default.css b/app/static/css/default.css index 475617d..dfe0f62 100644 --- a/app/static/css/default.css +++ b/app/static/css/default.css @@ -111,17 +111,13 @@ div#navbarNavfooter li.nav-item { margin-right: 6px; } -/* Prevent nav items from wrapping to a second line */ +/* Prevent nav items from wrapping to a second line. + overflow is intentionally NOT set here — overflow-x:auto would + implicitly clip overflow-y too and hide dropdown menus that open + below the navbar. */ div#navbarNavheader .navbar-nav, div#navbarNavfooter .navbar-nav { flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: none; /* Firefox */ -} - -div#navbarNavheader .navbar-nav::-webkit-scrollbar, -div#navbarNavfooter .navbar-nav::-webkit-scrollbar { - display: none; /* Chrome/Safari */ } main, footer, header, nav { @@ -197,7 +193,6 @@ iframe{ /* 1. Make sure headers and footers can collapse */ header, footer { - overflow: hidden; /* choose a max-height that’s >= your tallest header/footer */ max-height: 200px; padding: 1rem; @@ -206,9 +201,11 @@ footer { padding var(--anim-duration) ease-in-out; } -/* 2. In fullscreen mode, collapse them */ +/* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here + so dropdown menus can escape the header in normal mode. */ body.fullscreen header, body.fullscreen footer { + overflow: hidden; max-height: 0; padding-top: 0; padding-bottom: 0; diff --git a/app/static/css/navigation.css b/app/static/css/navigation.css index 92ff0b8..168e519 100644 --- a/app/static/css/navigation.css +++ b/app/static/css/navigation.css @@ -1,9 +1,19 @@ -/* Top-level dropdown menu */ -.nav-item .dropdown-menu { - position: absolute; /* Important for positioning */ - top: 100%; /* Default opening direction: downwards */ +/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */ +.nav-item.dropdown > .dropdown-menu, +.nav-item.dropup > .dropdown-menu { + position: absolute; left: 0; - z-index: 1050; /* Ensures the menu appears above other elements */ + z-index: 1050; +} + +.nav-item.dropdown > .dropdown-menu { + top: 100%; + bottom: auto; +} + +.nav-item.dropup > .dropdown-menu { + top: auto; + bottom: 100%; } /* Submenu position */ diff --git a/app/static/js/navigation.js b/app/static/js/navigation.js index 44fff26..ec7258c 100644 --- a/app/static/js/navigation.js +++ b/app/static/js/navigation.js @@ -1,6 +1,58 @@ document.addEventListener('DOMContentLoaded', () => { - const menuItems = document.querySelectorAll('.nav-item.dropdown'); - const subMenuItems = document.querySelectorAll('.dropdown-submenu'); + function getDirectChildByClass(item, className) { + return Array.from(item.children).find(child => child.classList?.contains(className)); + } + + function getMenu(item) { + return getDirectChildByClass(item, 'dropdown-menu'); + } + + function getToggle(item) { + return getDirectChildByClass(item, 'dropdown-toggle'); + } + + function isTopLevelDropdown(item) { + return ( + item.classList.contains('nav-item') && + (item.classList.contains('dropdown') || item.classList.contains('dropup')) + ); + } + + function chooseDirection(item) { + const rect = item.getBoundingClientRect(); + const spaceAbove = rect.top; + const spaceBelow = window.innerHeight - rect.bottom; + if (spaceAbove > spaceBelow) { + item.classList.add('dropup'); + item.classList.remove('dropdown'); + } else { + item.classList.add('dropdown'); + item.classList.remove('dropup'); + } + } + + function ensureDropdownInstances(root = document) { + const scope = root && root.querySelectorAll ? root : document; + scope + .querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle') + .forEach(toggle => { + toggle.setAttribute('data-bs-toggle', 'dropdown'); + if (!toggle.hasAttribute('aria-expanded')) { + toggle.setAttribute('aria-expanded', 'false'); + } + if (window.bootstrap?.Dropdown) { + // Use Popper strategy: 'fixed' so the menu is positioned relative + // to the viewport and escapes ancestors with overflow:hidden + // (e.g.
which clips for the fullscreen-collapse animation). + window.bootstrap.Dropdown.getInstance(toggle)?.dispose(); + new window.bootstrap.Dropdown(toggle, { + popperConfig(defaultBsPopperConfig) { + return { ...defaultBsPopperConfig, strategy: 'fixed' }; + }, + }); + } + }); + } function addMenuEventListeners(items, isTopLevel) { items.forEach(item => { @@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => { function onMouseEnter() { clearTimeout(timeout); - openMenu(item, isTopLevel); + openMenu(item, isTopLevel, 'hover'); } function onMouseLeave() { @@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => { // Open and adjust position on click item.addEventListener('click', (e) => { + const toggle = getToggle(item); + const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target)); + + if (isTopLevel && !clickedToggle) { + e.stopPropagation(); + return; + } + e.stopPropagation(); // Prevents menus from closing when clicking inside + if (isTopLevel) { + e.preventDefault(); + if (window.bootstrap?.Dropdown) { + if (item.dataset.openedBy === 'click') { + closeMenu(item); + } else if (getMenu(item)) { + item.dataset.openedBy = 'click'; + item.classList.add('open'); + chooseDirection(item); + window.bootstrap.Dropdown.getOrCreateInstance(toggle).show(); + } + return; + } + } if (item.classList.contains('open')) { closeMenu(item); } else { @@ -35,39 +109,59 @@ document.addEventListener('DOMContentLoaded', () => { }); } + const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup'; + function addAllMenuEventListeners() { - const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown'); + const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR); const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu'); addMenuEventListeners(updatedMenuItems, true); addMenuEventListeners(updatedSubMenuItems, false); } + ensureDropdownInstances(); addAllMenuEventListeners(); // Global click listener to close menus when clicking outside document.addEventListener('click', () => { + const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR); + const subMenuItems = document.querySelectorAll('.dropdown-submenu'); [...menuItems, ...subMenuItems].forEach(item => closeMenu(item)); }); - function openMenu(item, isTopLevel) { + function openMenu(item, isTopLevel, openedBy = 'script') { item.classList.add('open'); - const submenu = item.querySelector('.dropdown-menu'); - if (submenu) { - submenu.style.display = 'block'; - submenu.style.opacity = '1'; - submenu.style.visibility = 'visible'; - adjustMenuPosition(submenu, item, isTopLevel); + const submenu = getMenu(item); + if (!submenu) return; + if (isTopLevel) { + item.dataset.openedBy = openedBy; + const toggle = getToggle(item); + if (toggle && window.bootstrap?.Dropdown) { + chooseDirection(item); + window.bootstrap.Dropdown.getOrCreateInstance(toggle).show(); + return; + } } + submenu.style.display = 'block'; + submenu.style.opacity = '1'; + submenu.style.visibility = 'visible'; + adjustMenuPosition(submenu, item, isTopLevel); } function closeMenu(item) { item.classList.remove('open'); - const submenu = item.querySelector('.dropdown-menu'); - if (submenu) { - submenu.style.display = 'none'; - submenu.style.opacity = '0'; - submenu.style.visibility = 'hidden'; + delete item.dataset.openedBy; + const submenu = getMenu(item); + if (!submenu) return; + if (isTopLevelDropdown(item)) { + const toggle = getToggle(item); + if (toggle && window.bootstrap?.Dropdown) { + window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide(); + return; + } } + submenu.style.display = 'none'; + submenu.style.opacity = '0'; + submenu.style.visibility = 'hidden'; } function isSmallScreen() { diff --git a/app/templates/moduls/navigation.html.j2 b/app/templates/moduls/navigation.html.j2 index 93e0aea..6240636 100644 --- a/app/templates/moduls/navigation.html.j2 +++ b/app/templates/moduls/navigation.html.j2 @@ -85,7 +85,7 @@ {% else %}