Files
com.gregkilmer.www/src/components/nav/MobileMenu.tsx
T
Claude 9ecd4d0e59 Use Patiun icons, motifs, seal logo and gold accents across the site
- Icons: Patiun stroke icon set (13 approved + 73 new) in src/lib/brand-icons.ts;
  Icon.astro and a React BrandIcon resolve mdi names to brand icons, keeping
  mdi only for third-party logos. Inline island icons (mobile menu, carousel,
  form success, board game shelf, product tile) switched too.
- Motifs: layered seal turns in the hero corner; astrolabe on the Professional
  hero; Starlight recolors of the transmutation seal and tria prima array
  behind light sections (outlined ring text, no fonts needed). Removed the
  generic sacred-geometry and astrolabe drawings.
- Gold accents: image frames and corner stars, quote bars, dividers (new
  star-line divider), nav group divider, scrolled nav rule, footer rule.
- Nav and footer use the Patiun seal; nav tagline back to "Developer. Maker.
  Storyteller."; "Web Development · a Patiun LLC company" moved to the
  footer copyright line.
- Mobile menu: Midnight drawer, visible close button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 02:20:55 +00:00

140 lines
4.9 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';
import BrandIcon from '@components/brand/BrandIcon';
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;
}
// Patiun brand icons, resolved by the same names the nav config uses.
function Icon({ name, className }: { name: string; className?: string }) {
return <BrandIcon name={name} className={className} />;
}
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="relative z-[60] inline-flex h-10 w-10 items-center justify-center rounded-lg border"
style={{ color: 'var(--color-starlight)', borderColor: 'color-mix(in srgb, var(--color-gold) 45%, transparent)' }}
>
{open ? (
<BrandIcon name="close" size={22} />
) : (
<BrandIcon name="menu" size={22} />
)}
</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={{
// Night-sky drawer to match the nav it opens from.
backgroundColor: 'var(--color-midnight)',
borderLeft: '1px solid color-mix(in srgb, var(--color-gold) 45%, transparent)',
}}
>
{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-teal-bright)' : 'var(--color-starlight)',
backgroundColor: active
? 'color-mix(in srgb, var(--color-teal-bright) 14%, transparent)'
: 'transparent',
}}
>
<Icon name={item.icon} />
{item.label}
</a>
</li>
);
})}
</ul>
))}
</motion.nav>
</>
)}
</AnimatePresence>
</div>
);
}