mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-14 09:15:32 +00:00
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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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 that’s >= your tallest header/footer */
|
/* choose a max-height that’s >= 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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
70
tests/unit/test_navigation_template.py
Normal file
70
tests/unit/test_navigation_template.py
Normal 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()
|
||||||
Reference in New Issue
Block a user