mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
9ecd4d0e59
- 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
140 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|