fix(navigation): unclip dropdowns and flip toward the side with more space

- Move <header> overflow:hidden into body.fullscreen scope and drop the
  implicit-vertical-clip overflow-x:auto from .navbar-nav so dropdown
  menus can escape the navbar.
- Drive top-level dropdowns through bootstrap.Dropdown (popperConfig
  strategy:'fixed'), and add a chooseDirection() helper that toggles
  .dropup/.dropdown on the .nav-item based on space above vs below
  before each show. Split the navigation.css rules to position the menu
  with top:100% or bottom:100% accordingly.
- Mark the dropdown toggle with data-bs-toggle="dropdown" in the
  template; cover that with a Jinja-rendered unit test and add Cypress
  specs for the header (opens downward) and footer (flips to .dropup)
  cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 02:26:02 +02:00
parent 3301f8d95f
commit f3c15e3e1c
6 changed files with 269 additions and 33 deletions

View File

@@ -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', () => { describe('Dynamic Popup', () => {
const base = { const base = {

View File

@@ -111,17 +111,13 @@ div#navbarNavfooter li.nav-item {
margin-right: 6px; 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#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav { div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap; 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 { main, footer, header, nav {
@@ -197,7 +193,6 @@ iframe{
/* 1. Make sure headers and footers can collapse */ /* 1. Make sure headers and footers can collapse */
header, header,
footer { footer {
overflow: hidden;
/* choose a max-height thats >= your tallest header/footer */ /* choose a max-height thats >= your tallest header/footer */
max-height: 200px; max-height: 200px;
padding: 1rem; padding: 1rem;
@@ -206,9 +201,11 @@ footer {
padding var(--anim-duration) ease-in-out; 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 header,
body.fullscreen footer { body.fullscreen footer {
overflow: hidden;
max-height: 0; max-height: 0;
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;

View File

@@ -1,9 +1,19 @@
/* Top-level dropdown menu */ /* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
.nav-item .dropdown-menu { .nav-item.dropdown > .dropdown-menu,
position: absolute; /* Important for positioning */ .nav-item.dropup > .dropdown-menu {
top: 100%; /* Default opening direction: downwards */ position: absolute;
left: 0; 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 */ /* Submenu position */

View File

@@ -1,6 +1,58 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const menuItems = document.querySelectorAll('.nav-item.dropdown'); function getDirectChildByClass(item, className) {
const subMenuItems = document.querySelectorAll('.dropdown-submenu'); 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. <header> 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) { function addMenuEventListeners(items, isTopLevel) {
items.forEach(item => { items.forEach(item => {
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
function onMouseEnter() { function onMouseEnter() {
clearTimeout(timeout); clearTimeout(timeout);
openMenu(item, isTopLevel); openMenu(item, isTopLevel, 'hover');
} }
function onMouseLeave() { function onMouseLeave() {
@@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Open and adjust position on click // Open and adjust position on click
item.addEventListener('click', (e) => { 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 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')) { if (item.classList.contains('open')) {
closeMenu(item); closeMenu(item);
} else { } else {
@@ -35,40 +109,60 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
function addAllMenuEventListeners() { function addAllMenuEventListeners() {
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown'); const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu'); const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
addMenuEventListeners(updatedMenuItems, true); addMenuEventListeners(updatedMenuItems, true);
addMenuEventListeners(updatedSubMenuItems, false); addMenuEventListeners(updatedSubMenuItems, false);
} }
ensureDropdownInstances();
addAllMenuEventListeners(); addAllMenuEventListeners();
// Global click listener to close menus when clicking outside // Global click listener to close menus when clicking outside
document.addEventListener('click', () => { document.addEventListener('click', () => {
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item)); [...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
}); });
function openMenu(item, isTopLevel) { function openMenu(item, isTopLevel, openedBy = 'script') {
item.classList.add('open'); item.classList.add('open');
const submenu = item.querySelector('.dropdown-menu'); const submenu = getMenu(item);
if (submenu) { 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.display = 'block';
submenu.style.opacity = '1'; submenu.style.opacity = '1';
submenu.style.visibility = 'visible'; submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel); adjustMenuPosition(submenu, item, isTopLevel);
} }
}
function closeMenu(item) { function closeMenu(item) {
item.classList.remove('open'); item.classList.remove('open');
const submenu = item.querySelector('.dropdown-menu'); delete item.dataset.openedBy;
if (submenu) { 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.display = 'none';
submenu.style.opacity = '0'; submenu.style.opacity = '0';
submenu.style.visibility = 'hidden'; submenu.style.visibility = 'hidden';
} }
}
function isSmallScreen() { function isSmallScreen() {
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg' return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'

View File

@@ -85,7 +85,7 @@
{% else %} {% else %}
<!-- Dropdown Menu --> <!-- Dropdown Menu -->
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-display="dynamic" aria-expanded="false"> <a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
{% if item.icon is defined and item.icon.class is defined %} {% if item.icon is defined and item.icon.class is defined %}
{{ render_icon_and_name(item) }} {{ render_icon_and_name(item) }}
{% else %} {% else %}

View File

@@ -0,0 +1,70 @@
import unittest
from html.parser import HTMLParser
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
class AnchorCollector(HTMLParser):
def __init__(self):
super().__init__()
self.anchors = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.anchors.append(dict(attrs))
class TestNavigationTemplate(unittest.TestCase):
def test_top_level_dropdowns_have_bootstrap_toggle_attribute(self):
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
environment = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
)
environment.globals["url_for"] = lambda _endpoint, filename: (
f"/static/{filename}"
)
rendered = environment.get_template("moduls/navigation.html.j2").render(
menu_type="header",
platform={
"titel": "Portfolio",
"logo": {"cache": "logo.png"},
},
navigation={
"header": {
"children": [
{
"name": "Apps",
"description": "Application menu",
"icon": {"class": "fa-solid fa-grid"},
"children": [
{
"name": "Example",
"description": "Example app",
"icon": {"class": "fa-solid fa-link"},
"url": "https://example.test",
}
],
}
]
}
},
)
parser = AnchorCollector()
parser.feed(rendered)
dropdown_toggles = [
anchor
for anchor in parser.anchors
if "nav-link" in anchor.get("class", "")
and "dropdown-toggle" in anchor.get("class", "")
]
self.assertEqual(len(dropdown_toggles), 1)
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
if __name__ == "__main__":
unittest.main()