Skip to content

Latest commit

 

History

History
410 lines (323 loc) · 14.2 KB

File metadata and controls

410 lines (323 loc) · 14.2 KB

Architecture — multi-level Bootstrap nav

A four-level navbar built on top of Bootstrap 5.3 with a split tap-target mobile pattern (each section heading is two hit-targets: a navigation link and a separate expand caret) and out-of-the-box Bootstrap dropdowns on desktop.

The implementation has three deliberate constraints:

  1. Minimal CSS and JS. Every rule earns its place. ~70 lines of CSS, ~90 of JS.
  2. Scoped. Every CSS selector and JS query is anchored to #mainNav so it cannot bleed into other Bootstrap components on the page.
  3. Separated. All behaviour lives in nav.js, not inline.

Files: index.html, styles.css, nav.js.


HTML deviations from OOB Bootstrap

The HTML is the standard navbar-expand-lg + offcanvas-lg responsive offcanvas pattern, with three deliberate additions:

1. .dropdown-submenu for nested dropdowns

Bootstrap 5 doesn't ship nested dropdowns. We use the community convention: each nested level wraps its <ul class="dropdown-menu"> in a parent <li class="dropdown-submenu">. The corresponding CSS (three rules in styles.css) provides the desktop flyout.

2. Split tap-target on every section heading

After every section heading link (Products, Software, Web Apps, …) and before its <ul class="dropdown-menu">, we add a <button>:

<li class="nav-item dropdown">
  <a class="nav-link dropdown-toggle" href="#" id="productsLink"
     data-bs-toggle="dropdown" aria-expanded="false">Products</a>
  <button class="nav-expand dropdown-toggle" type="button"
          aria-expanded="false" aria-label="Expand Products section"></button>
  <ul class="dropdown-menu" aria-labelledby="productsLink"></ul>
</li>

That gives mobile users two hit-targets per row in the same accessible DOM order: tap the text to navigate to the section page, tap the caret button to expand the submenu. CSS hides the button at the lg breakpoint so desktop sees only the standard Bootstrap dropdown toggle.

3. data-bs-toggle="dropdown" on the LINK, not the button

Placing the Bootstrap data attribute on the link means desktop clicks on the link itself open the dropdown (OOB behaviour, with built-in preventDefault and auto-close). On mobile we intercept the link click in JS and stopPropagation() so Bootstrap's delegated listener never sees it — we navigate the prototype's page instead.


CSS — styles.css

The whole file is around 70 lines of substantive CSS (the rest is whitespace + commentary). All rules are scoped to #mainNav.

Why ID scoping?

Using the ID #mainNav as the prefix achieves two goals at once:

  • Isolation. Nothing leaks into other Bootstrap components on the page. A <nav> or <aside> outside #mainNav keeps every default.
  • Specificity. #mainNav .nav-link is 1,1,0 which beats Bootstrap's .navbar-dark .navbar-nav .nav-link cascade (0,3,0) without needing !important or longer selectors.

CSS at every viewport — nested submenu positioning

#mainNav .dropdown-submenu { position: relative; }
#mainNav .dropdown-submenu > .dropdown-menu {
  top: 0; left: 100%; margin-top: -1px;
}

These two rules add positioning context for Bootstrap's missing nested- dropdown pattern. They apply at all viewports — the desktop flyout uses this positioning, and on mobile our position: static reset overrides the left: 100% so the nested menu expands inline.

The hover-to-open rule lives in the desktop @media block — see Close-on-second-tap fix below.

Mobile-only (< lg)

Full-screen offcanvas. Bootstrap's default is 400 px wide; one CSS variable assignment overrides that.

#mainNav { --bs-offcanvas-width: 100%; }

Split-row flex layout + 1 rem indent per level. The <li> becomes a 3-child flex container: link grows, button takes content width, menu wraps to its own row beneath. flex-basis: calc(100% - 1rem) plus margin-left: 1rem gives a clean indent without overflow.

#mainNav .nav-item.dropdown,
#mainNav .dropdown-submenu {
  display: flex;
  flex-wrap: wrap;
}
#mainNav .nav-item.dropdown > .nav-link,
#mainNav .dropdown-submenu > .dropdown-item {
  flex: 1 0 0;
  width: auto;   /* override Bootstrap's .dropdown-item { width: 100% } */
}
#mainNav .nav-item.dropdown > .dropdown-menu,
#mainNav .dropdown-submenu > .dropdown-menu {
  flex-basis: calc(100% - 1rem);
  margin-left: 1rem;
}

Inline submenu positioning. Reset the desktop flyout so nested menus expand below their parent instead of flying right.

#mainNav .dropdown-submenu > .dropdown-menu {
  position: static;
  left: auto;
}

Flat dropdown panels. Strip Bootstrap's panel styling so the menu reads as part of the inline accordion.

#mainNav .dropdown-menu {
  padding: 0; margin: 0; border: 0; background-color: transparent;
}

Consistent item padding. 0.6 rem vertical for a comfortable tap target, 0.5 rem left to leave room for the active vertical bar.

#mainNav .nav-link,
#mainNav .dropdown-item {
  padding: 0.6rem 1rem 0.6rem 0.5rem;
}

The expand button. Transparent visual, 44 px min-width for WCAG tap targets, focus-visible outline for keyboard users.

#mainNav .nav-expand {
  background: transparent; border: 0; color: inherit; cursor: pointer;
  min-width: 44px; padding: 0.6rem 1rem;
  display: inline-flex; align-items: center; justify-content: center;
}
#mainNav .nav-expand:focus-visible {
  outline: 2px solid var(--bs-primary); outline-offset: -2px;
}

Caret rotation driven by aria-expanded. No :has() dependency — the JS already keeps aria-expanded in sync, so we can use it as the state selector. ▸ closed, ▼ open.

#mainNav .nav-expand::after {
  transform: rotate(-90deg);
  transition: transform 0.2s ease;
}
#mainNav .nav-expand[aria-expanded="true"]::after {
  transform: rotate(0deg);
}

Hide the link's Bootstrap caret on mobile — the .nav-expand button carries the caret instead.

#mainNav .nav-link.dropdown-toggle::after { display: none; }

Nav-link colours. Bootstrap's .navbar-dark .navbar-nav .nav-link cascade would force white-on-white text on the offcanvas. The #mainNav specificity wins so source order doesn't matter.

#mainNav .nav-link { color: var(--bs-body-color); }
#mainNav .nav-link:hover,
#mainNav .nav-link:focus { color: var(--bs-primary); }
#mainNav .show > .nav-link { color: var(--bs-body-color); }

The third rule beats Bootstrap's .navbar-dark .navbar-nav .show > .nav-link { color: rgb(255,255,255,1) } which would otherwise make a section heading vanish (pure white on white) the moment its dropdown opens.

Active indicator. A 5 px primary bar drawn via inset box-shadow (zero layout impact) plus primary text colour for the selected leaf.

#mainNav .nav-link.active,
#mainNav .dropdown-item.active {
  box-shadow: inset 5px 0 0 var(--bs-primary);
  color: var(--bs-primary);
  background-color: transparent;
}

Ancestor path. Every section heading on the path to the selected leaf gets primary text (added by markParents() in the JS).

#mainNav .nav-link.parent-active,
#mainNav .dropdown-item.parent-active {
  color: var(--bs-primary);
}

Tap-state suppression. Bootstrap's :active on .dropdown-item forces color: #fff + blue bg, which makes the text vanish on every tap against the white offcanvas. Override.

#mainNav .dropdown-item:active {
  background-color: transparent;
  color: var(--bs-body-color);
}

Desktop-only (>= lg)

#mainNav .nav-expand { display: none; }
#mainNav .dropdown-submenu:hover > .dropdown-menu { display: block; }

Two rules. The first hides the mobile expand button so the desktop navbar shows just the standard Bootstrap dropdown-toggle. The second is the nested-submenu hover-open — see Close-on-second-tap fix for why it lives in the desktop @media block specifically.

Everything else falls back to Bootstrap's OOB navbar behaviour: horizontal layout, click-to-open dropdowns, the built-in caret, single-open auto-close, click-outside to dismiss.

Close-on-second-tap fix

The hover-to-open rule for nested submenus —

#mainNav .dropdown-submenu:hover > .dropdown-menu {
  display: block;
}

— is the natural way to wire up Bootstrap's missing nested dropdowns at desktop. The temptation is to put it at the top of the file, outside any media query, so it applies everywhere. Don't. On a touch device :hover persists on the element after a tap until a different element is tapped. Sequence on a mobile L2 caret:

  1. Tap Software's caret → JS adds .show → menu visible (display: block)
  2. The same tap activated :hover on the parent .dropdown-submenu
  3. Tap the caret again → JS removes .show
  4. Bug: Bootstrap's default .dropdown-menu { display: none } (specificity 0,1,0) is now overridden by the still-active :hover rule (specificity 1,3,0) → menu stays visible

The fix is one move, not a new rule: scope the :hover rule to @media (min-width: 992px) only. Mobile then has zero :hover selectors against .dropdown-submenu, so the JS-managed .show / no-.show state is the sole source of truth, and second-tap-to-close works.

Verified at mobile: zero matching :hover rules; tapping Software's caret twice removes .show and computes display: none. Verified at desktop: the rule matches, hovering Software opens the L2 submenu positioned at left: 100%.


JS — nav.js

Around 90 lines of substantive code (the rest is whitespace + comments). Three responsibilities, all scoped to a single nav reference.

1. Expand button toggles the next-sibling menu (mobile only)

nav.querySelectorAll('.nav-expand').forEach(function (btn) {
  btn.addEventListener('click', function (e) {
    if (!isMobile()) return;            // hidden at desktop, never fires there anyway
    e.preventDefault();
    e.stopPropagation();                // keep Bootstrap's listeners out of this
    var menu = btn.nextElementSibling;
    var open = menu.classList.toggle('show');
    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
    // Mirror onto the section link if it has aria-expanded
    var sectionLink = btn.previousElementSibling;
    if (sectionLink && sectionLink.hasAttribute('aria-expanded')) {
      sectionLink.setAttribute('aria-expanded', open ? 'true' : 'false');
    }
  });
});

2. Anchor clicks navigate (or open Bootstrap dropdown on desktop)

nav.querySelectorAll('a').forEach(function (link) {
  link.addEventListener('click', function (e) {
    e.preventDefault();

    // Desktop, a Bootstrap dropdown-toggle link → return early.
    // The event bubbles, Bootstrap's delegated listener opens the dropdown.
    if (!isMobile() && link.matches('[data-bs-toggle="dropdown"]')) return;

    // Mobile → stop the event so Bootstrap doesn't also try to toggle.
    if (isMobile()) e.stopPropagation();

    // Move .active, mark ancestors .parent-active, update the page content
    nav.querySelectorAll('.nav-link.active, .dropdown-item.active').forEach(...);
    link.classList.add('active');
    link.setAttribute('aria-current', 'page');
    markParents(link);
    // ... pageTitle / pageBreadcrumb / pageBody assignments ...

    closeAll();

    var oc = bootstrap.Offcanvas.getInstance(nav);
    if (oc && nav.classList.contains('show')) oc.hide();
  });
});

3. Cleanup after every navigation

function closeAll() {
  // Bootstrap-managed dropdowns (desktop): use the API so Popper + ARIA
  // are torn down cleanly. On mobile no instances exist (our handler
  // stops the event from reaching Bootstrap's listener), so this loop
  // is a no-op there.
  nav.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(function (t) {
    var inst = bootstrap.Dropdown.getInstance(t);
    if (inst) inst.hide();
  });
  // Sweep up the mobile-JS-managed menus + reset every aria-expanded.
  nav.querySelectorAll('.dropdown-menu.show').forEach(...);
  nav.querySelectorAll('.nav-expand, .nav-link[aria-expanded]').forEach(...);
}

Why query [data-bs-toggle="dropdown"] instead of .nav-item.dropdown.show?

Bootstrap 5.3 adds .show to the toggle (the link in our HTML) and the menu, but not to the parent <li>. The natural-looking .nav-item.dropdown.show selector would never match a Bootstrap-opened dropdown — querying the toggles directly is the reliable approach. (E6 hit this bug during testing; E7 starts from the right answer.)


Test coverage

Same test plan as E6:

Mobile (375 × 812). Offcanvas slides in full-screen, white panel, header + close button; section heading rows have split tap-targets; tap text navigates (offcanvas closes, breadcrumb updates); tap caret expands the submenu without closing the offcanvas; multi-open works at every level; carets rotate ▸/▼; 1 rem indent per nesting level; selected leaf shows the 5 px primary bar; every ancestor of the selected leaf is primary text; section heading text stays visible when expanded (no white-on-white); no items vanish on tap; the link's Bootstrap caret is hidden so only the button caret shows.

M-fix (close-on-second-tap). Tap a section's expand caret twice; the second tap must close the submenu — both .show removed AND computed display is none. Verified at L2 (Software, Hardware) and L3 (Web Apps).

Desktop (1280 × 800). No hamburger, no expand buttons; horizontal navbar with carets on Products and Solutions; clicking a top-level toggle opens the dropdown absolutely-positioned below; hover opens L2 flyouts and L3 sub-flyouts; clicking a leaf or section heading link navigates and closes the dropdown chain; single-open auto-close (OOB); click-outside dismisses.

All checks verified visually + via getComputedStyle / getBoundingClientRect / aria-expanded inspection during the build.

Production goals checklist

  • Minimal. ~70 lines of substantive CSS, ~90 of JS.
  • Scoped. Every CSS selector starts with #mainNav. The JS only reaches outside that root to read/write #page-title, #page-breadcrumb, #page-body — the prototype's three demo elements.
  • Separated. All JS lives in nav.js. index.html has only a single <script src> line.