mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
/**
|
|
* MobileMenu.tsx
|
|
* ---------------------------------------------------------------------------
|
|
* React island that powers the hamburger menu on small screens. Renders the
|
|
* same two-group nav structure as the desktop bar (imported via a data prop so
|
|
* the source of truth stays in site.ts).
|
|
*
|
|
* Accessibility: the button is aria-expanded/aria-controls wired to the panel,
|
|
* Escape closes, clicking a link closes, and focus is returned to the toggle.
|
|
* Animations respect prefers-reduced-motion (Framer Motion reads the media query
|
|
* automatically when we pass the transition).
|
|
*/
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
|
|
export interface MobileNavItem {
|
|
label: string;
|
|
href: string;
|
|
icon: string;
|
|
}
|
|
export interface MobileNavGroup {
|
|
items: MobileNavItem[];
|
|
}
|
|
|
|
interface Props {
|
|
groups: MobileNavGroup[];
|
|
/** Current path for active link highlighting. */
|
|
currentPath: string;
|
|
}
|
|
|
|
// Small inline mdi-like icons keyed by name (keeps the menu self-contained).
|
|
const ICON_PATHS: Record<string, string> = {
|
|
home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
|
|
account: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 10c-4.4 0-8 2.2-8 5v1h16v-1c0-2.8-3.6-5-8-5z',
|
|
briefcase:
|
|
'M10 16h4a1 1 0 0 0 1-1v-1H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-1v1a3 3 0 0 1-3 3h-4a3 3 0 0 1-3-3v-1z',
|
|
'dice-multiple':
|
|
'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z',
|
|
email:
|
|
'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z',
|
|
};
|
|
|
|
function Icon({ name, className }: { name: string; className?: string }) {
|
|
return (
|
|
<svg className={className} width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d={ICON_PATHS[name] ?? ''} />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function isActive(href: string, path: string): boolean {
|
|
if (href === '/') return path === '/';
|
|
return path === href || path.startsWith(href + '/');
|
|
}
|
|
|
|
export default function MobileMenu({ groups, currentPath }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
// Escape closes the menu; lock body scroll while open.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setOpen(false);
|
|
buttonRef.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
document.body.style.overflow = 'hidden';
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
document.body.style.overflow = '';
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="md:hidden">
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
aria-expanded={open}
|
|
aria-controls="mobile-menu"
|
|
aria-label={open ? 'Close menu' : 'Open menu'}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
|
style={{ color: 'var(--color-text)', borderColor: 'var(--color-border)' }}
|
|
>
|
|
{open ? (
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
</svg>
|
|
) : (
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{open && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={() => setOpen(false)}
|
|
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
|
|
/>
|
|
{/* Panel */}
|
|
<motion.nav
|
|
id="mobile-menu"
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
transition={{ type: 'tween', duration: 0.28 }}
|
|
className="fixed right-0 top-0 z-50 flex h-[100dvh] w-[min(82vw,22rem)] flex-col gap-6 overflow-y-auto p-6 pt-20 shadow-2xl"
|
|
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
|
>
|
|
{groups.map((group, gi) => (
|
|
<ul key={gi} className="flex flex-col gap-1">
|
|
{group.items.map((item) => {
|
|
const active = isActive(item.href, currentPath);
|
|
return (
|
|
<li key={item.href}>
|
|
<a
|
|
href={item.href}
|
|
onClick={() => setOpen(false)}
|
|
className="flex items-center gap-3 rounded-lg px-3 py-3 text-lg font-medium transition-colors"
|
|
style={{
|
|
color: active ? 'var(--color-accent)' : 'var(--color-text)',
|
|
backgroundColor: active
|
|
? 'color-mix(in srgb, var(--color-accent) 14%, transparent)'
|
|
: 'transparent',
|
|
}}
|
|
>
|
|
<Icon name={item.icon} />
|
|
{item.label}
|
|
</a>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
))}
|
|
</motion.nav>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|