Monday, January 12, 2026

Bootstrap Module 29

 Module 29 : Advanced Navbar with Dropdowns.

File: AdvancedNavbar. jsx

Type: React component (single-file)


HOW TO USE:

- Framer Motion is used for subtle animations: `npm install framer-motion`

- The component is self-contained and export-defaults `AdvancedNavbar`.

                  


import React, { useState, useRef, useEffect } from "react";

import { motion, AnimatePresence } from "framer-motion";


// -----------------------------

// Utility hooks and helpers

// -----------------------------


// useOnClickOutside: closes menus when clicking outside

function useOnClickOutside(refs, handler) {

  useEffect(() => {

    function listener(e) {

      // if any ref contains target, do nothing

      for (const ref of refs) {

        if (!ref.current) continue;

        if (ref.current.contains(e.target)) return;

      }

      handler(e);

    }

    document.addEventListener("mousedown", listener);

    document.addEventListener("touchstart", listener);

    return () => {

      document.removeEventListener("mousedown", listener);

      document.removeEventListener("touchstart", listener);

    };

  }, [refs, handler]);

}


// Trap focus inside a container (simple implementation for mobile menu)

function useFocusTrap(active, containerRef) {

  useEffect(() => {

    if (!active) return;

    const container = containerRef.current;

    if (!container) return;


    const focusableSelectors = [

      "a[href]",

      "area[href]",

      "input:not([disabled])",

      "select:not([disabled])",

      "textarea:not([disabled])",

      "button:not([disabled])",

      "[tabindex]:not([tabindex='-1'])",

    ].join(",");


    const focusables = Array.from(container.querySelectorAll(focusableSelectors));

    const first = focusables[0];

    const last = focusables[focusables.length - 1];

    if (first) first.focus();


    function handleKey(e) {

      if (e.key !== "Tab") return;

      if (e.shiftKey) {

        if (document.activeElement === first) {

          e.preventDefault();

          last.focus();

        }

      } else {

        if (document.activeElement === last) {

          e.preventDefault();

          first.focus();

        }

      }

    }


    container.addEventListener("keydown", handleKey);

    return () => container.removeEventListener("keydown", handleKey);

  }, [active, containerRef]);

}


// Keyboard helper for menu items: Arrow navigation

function handleMenuKeyNavigation(e, itemsRefs, indexSetter, currentIndex) {

  const max = itemsRefs.length - 1;

  if (e.key === "ArrowRight") {

    e.preventDefault();

    const next = currentIndex === max ? 0 : currentIndex + 1;

    indexSetter(next);

    itemsRefs[next]?.current?.focus();

  } else if (e.key === "ArrowLeft") {

    e.preventDefault();

    const prev = currentIndex === 0 ? max : currentIndex - 1;

    indexSetter(prev);

    itemsRefs[prev]?.current?.focus();

  }

}


// -----------------------------

// Dropdown component (single level)

// -----------------------------


function Dropdown({ label, items }) {

  // label: string

  // items: [{label, href, submenu?}]

  const [open, setOpen] = useState(false);

  const btnRef = useRef(null);

  const panelRef = useRef(null);


  useOnClickOutside([btnRef, panelRef], () => setOpen(false));


  // Close on Escape

  useEffect(() => {

    function onKey(e) {

      if (e.key === "Escape") setOpen(false);

    }

    document.addEventListener("keydown", onKey);

    return () => document.removeEventListener("keydown", onKey);

  }, []);


  return (

    <div className="relative" onBlur={(e) => {

      // On blur, if focus moves outside this dropdown, close.

      const currentTarget = e.currentTarget;

      window.setTimeout(() => {

        if (!currentTarget.contains(document.activeElement)) setOpen(false);

      }, 0);

    }}>

      <button

        ref={btnRef}

        aria-haspopup="true"

        aria-expanded={open}

        onClick={() => setOpen((v) => !v)}

        onKeyDown={(e) => {

          if (e.key === "ArrowDown") {

            e.preventDefault();

            setOpen(true);

            // focus first item after opening

            setTimeout(() => panelRef.current?.querySelector("a")?.focus(), 0);

          }

        }}

        className="px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"

      >

        {label}

      </button>


      <AnimatePresence>

        {open && (

          <motion.div

            ref={panelRef}

            initial={{ opacity: 0, y: -6 }}

            animate={{ opacity: 1, y: 0 }}

            exit={{ opacity: 0, y: -6 }}

            className="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50"

            role="menu"

            aria-label={label}

          >

            <div className="py-1">

              {items.map((it, i) => (

                <a

                  key={i}

                  href={it.href || "#"}

                  className="block px-4 py-2 text-sm hover:bg-gray-100 focus:outline-none"

                  role="menuitem"

                  tabIndex={0}

                >

                  {it.label}

                </a>

              ))}

            </div>

          </motion.div>

        )}

      </AnimatePresence>

    </div>

  );

}


// -----------------------------

// Multi-level dropdown (example implementation)

// -----------------------------


function MultiLevelDropdown({ label, groups }) {

  // groups: array of {title, items: [{label, href}]}

  const [open, setOpen] = useState(false);

  const rootRef = useRef(null);

  useOnClickOutside([rootRef], () => setOpen(false));


  return (

    <div className="relative" ref={rootRef}>

      <button

        aria-haspopup="true"

        aria-expanded={open}

        onClick={() => setOpen((v) => !v)}

        className="px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none"

      >

        {label}

      </button>


      <AnimatePresence>

        {open && (

          <motion.div

            initial={{ opacity: 0, y: -6 }}

            animate={{ opacity: 1, y: 0 }}

            exit={{ opacity: 0, y: -6 }}

            className="absolute left-0 mt-2 w-80 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50 p-4 grid grid-cols-2 gap-4"

          >

            {groups.map((g, gi) => (

              <div key={gi}>

                <p className="font-semibold text-sm mb-2">{g.title}</p>

                <ul>

                  {g.items.map((it, i) => (

                    <li key={i}>

                      <a href={it.href || "#"} className="block p-1 text-sm hover:bg-gray-100 rounded">

                        {it.label}

                      </a>

                    </li>

                  ))}

                </ul>

              </div>

            ))}

          </motion.div>

        )}

      </AnimatePresence>

    </div>

  );

}


// -----------------------------

// Main Navbar component

// -----------------------------


export default function AdvancedNavbar() 


{

  const [mobileOpen, setMobileOpen] = useState(false);

  const mobileRef = useRef(null);

  useFocusTrap(mobileOpen, mobileRef);


  // Example top-level nav items refs to support arrow navigation

  const topItems = [useRef(null), useRef(null), useRef(null), useRef(null)];

  const [activeTopIndex, setActiveTopIndex] = useState(0);


  useEffect(() => {

    // Optional: keyboard left/right across top-level items

    function handler(e) {

      if (document.activeElement && topItems.some(r => r.current === document.activeElement)) {

        const currentIndex = topItems.findIndex(r => r.current === document.activeElement);

        handleMenuKeyNavigation(e, topItems, setActiveTopIndex, currentIndex);

      }

    }

    document.addEventListener('keydown', handler);

    return () => document.removeEventListener('keydown', handler);

  }, []);


  const navData = {

    products: [

      { label: "Analytics", href: "/analytics" },

      { label: "Engagement", href: "/engagement" },

      { label: "Security", href: "/security" },

    ],

    solutions: [

      { title: "By Industry", items: [{ label: "Education", href: "/edu" }, { label: "Healthcare", href: "/health" }] },

      { title: "By Role", items: [{ label: "Engineering", href: "/eng" }, { label: "Marketing", href: "/mkt" }] },

    ],

  };


  return (

    <header className="bg-white shadow">

      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">

        <div className="flex justify-between items-center py-4">

          <div className="flex items-center space-x-4">

            <div className="text-2xl font-bold">Brand</div>

            <nav className="hidden md:flex items-center space-x-2" aria-label="Primary">

              <a ref={topItems[0]} href="#" className="px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none">Home</a>


              <div>

                <Dropdown label="Products" items={navData.products} />

              </div>


              <div>

                <MultiLevelDropdown label="Solutions" groups={navData.solutions} />

              </div>


              <a ref={topItems[3]} href="#" className="px-3 py-2 rounded-md hover:bg-gray-100 focus:outline-none">Pricing</a>

            </nav>

          </div>


          <div className="flex items-center space-x-4">

            <div className="hidden md:block">

              <input aria-label="Search" placeholder="Search" className="px-3 py-2 rounded-md border focus:outline-none focus:ring-2 focus:ring-indigo-500" />

            </div>


            <div className="md:hidden">

              <button

                onClick={() => setMobileOpen(true)}

                aria-label="Open menu"

                className="p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"

              >

                {/* simple hamburger icon */}

                <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />

                </svg>

              </button>

            </div>


            <div className="hidden md:block">

              <button className="px-4 py-2 bg-indigo-600 text-white rounded-md">Sign in</button>

            </div>

          </div>

        </div>

      </div>


      {/* Mobile slide-over menu */}

      <AnimatePresence>

        {mobileOpen && (

          <motion.div

            className="fixed inset-0 z-50"

            initial={{ opacity: 0 }}

            animate={{ opacity: 1 }}

            exit={{ opacity: 0 }}

          >

            {/* overlay */}

            <div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />


            {/* panel */}

            <motion.aside

              initial={{ x: "100%" }}

              animate={{ x: 0 }}

              exit={{ x: "100%" }}

              transition={{ type: "spring", stiffness: 300, damping: 30 }}

              className="absolute right-0 top-0 bottom-0 w-80 bg-white shadow-lg p-6 overflow-auto"

              role="dialog"

              aria-modal="true"

            >

              <div ref={mobileRef} className="flex flex-col h-full">

                <div className="flex items-center justify-between mb-6">

                  <div className="text-xl font-bold">Menu</div>

                  <button aria-label="Close menu" onClick={() => setMobileOpen(false)} className="p-2 rounded-md focus:outline-none">

                    <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

                    </svg>

                  </button>

                </div>


                <div className="space-y-4">

                  <a href="#" className="block p-2 rounded-md hover:bg-gray-100">Home</a>


                  <div>

                    <details>

                      <summary className="cursor-pointer p-2 rounded-md hover:bg-gray-100">Products</summary>

                      <div className="pl-4 mt-2 space-y-1">

                        {navData.products.map((p, i) => (

                          <a key={i} href={p.href} className="block p-2 rounded-md hover:bg-gray-100">{p.label}</a>

                        ))}

                      </div>

                    </details>

                  </div>


                  <div>

                    <details>

                      <summary className="cursor-pointer p-2 rounded-md hover:bg-gray-100">Solutions</summary>

                      <div className="pl-4 mt-2">

                        {navData.solutions.map((g, gi) => (

                          <div key={gi} className="mb-2">

                            <p className="font-semibold">{g.title}</p>

                            {g.items.map((it, i) => (

                              <a key={i} href={it.href} className="block p-1 pl-2 rounded hover:bg-gray-100">{it.label}</a>

                            ))}

                          </div>

                        ))}

                      </div>

                    </details>

                  </div>


                  <a href="#" className="block p-2 rounded-md hover:bg-gray-100">Pricing</a>


                  <div className="mt-4">

                    <button className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md">Sign in</button>

                  </div>

                </div>


                <div className="mt-auto pt-6 text-sm text-gray-500">

                  © Brand — Accessible Menu

                </div>

              </div>

            </motion.aside>

          </motion.div>

        )}

      </AnimatePresence>

    </header>

  );

}


/*


 EXERCISES

Exercise 1 — Basic dropdown

- Implement a simple dropdown using <button> + an absolutely positioned panel.

                  


- Add keyboard: Alt+Down or ArrowDown opens the menu; ArrowUp/Down moves between items; Esc closes it.

- Deliverable: working component + short writeup on ARIA used.


Exercise 2 — Multi-level dropdown

- Extend Exercise 1 to include nested submenu items.

- Important: ensure that submenus are reachable via keyboard and Esc closes only the focused submenu first.

- Deliverable: demo page and a screenshot/video showing keyboard navigation.


Exercise 3 — Mobile menu accessibility

- Implement a slide-over menu. Ensure focus is trapped inside when open and returned to the toggle when closed.

- Deliverable: short test plan demonstrating focus behavior.


(example)

- Functionality (40%): All interactions work across mouse/keyboard/touch.

- Accessibility (30%): ARIA attributes, focus management, tab order, screen reader friendliness.

- Code quality (20%): readability, decomposition, small reusable components.

- Visual polish & responsiveness (10%).


ADVANCED RESEARCH TOPICS  


1. Performance: measuring reflow cost of large dropdown trees; virtualization of long lists inside dropdowns.

2. Security: ensure menu links don't open untrusted content in unexpected contexts; sanitize dynamic hrefs.

3. nested menus vs mega-menus — when to use each; cognitive load research.

4. adapt menus for RTL languages and different text lengths.

5. ARIA deep-dive: differences between role=menu vs role=listbox; when to use combobox patterns.

6. Automation testing:Playwright/Cypress tests for keyboard navigation and focus management.


SUGGESTED EXTENSIONS / VARIATIONS



- Add typeahead for large menus: let users press a letter to jump to items starting with that letter.

- Add persisted open state for menus across route changes using UI state synced with URL hash.

- Add micro-interactions: delayed close on hover, but ensure keyboard-first users aren't affected.


No comments:

Post a Comment

Build a Powerful Dashboard with Bootstrap, PHP, MySQL & Python (Step-by-Step Guide) Assignment

Assignment: Full Stack Dashboard using Bootstrap, PHP, SQL, and Python will design and implement a dynamic dashboard that displays, manages,...