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