mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Initial Commit
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user