feat(ui): refine mobile bottom nav, add drag-to-close flyout, custom icons

This commit is contained in:
2026-06-20 00:09:11 +02:00
parent 67696b6afa
commit 9e024607c1
2 changed files with 49 additions and 47 deletions

View File

@@ -3,7 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { TransitionLink } from '@/components/ui/TransitionLink'; import { TransitionLink } from '@/components/ui/TransitionLink';
import { Home, Briefcase, Users, Mail } from 'lucide-react'; import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
export interface NavLink { export interface NavLink {
@@ -21,11 +21,18 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
const pathname = usePathname() || '/'; const pathname = usePathname() || '/';
const [isFlyoutOpen, setIsFlyoutOpen] = React.useState(false); const [isFlyoutOpen, setIsFlyoutOpen] = React.useState(false);
// We need to gather all links that should go into the flyout. // Scroll lock when flyout is open
// The user wants 'alle navigationspunkte daraus' (from the old burger menu). React.useEffect(() => {
// Old menu had: Kompetenzen, Über uns (Firma, Team, Zertifikate, Standorte), Referenzen, Karriere, Messen. if (isFlyoutOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isFlyoutOpen]);
// Construct our bottom nav items
const items = React.useMemo(() => { const items = React.useMemo(() => {
const homeUrl = `/${currentLocale}`; const homeUrl = `/${currentLocale}`;
const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`; const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`;
@@ -37,44 +44,38 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
id: 'home', id: 'home',
label: currentLocale === 'de' ? 'Start' : 'Home', label: currentLocale === 'de' ? 'Start' : 'Home',
url: homeUrl, url: homeUrl,
icon: <Home className="w-5 h-5 md:w-6 md:h-6" strokeWidth={2.5} />, icon: <Home className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />,
exactMatch: true exactMatch: true
}, },
{ {
id: 'kompetenzen', id: 'kompetenzen',
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'), label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
url: competenciesUrl, url: competenciesUrl,
icon: <Briefcase className="w-5 h-5 md:w-6 md:h-6" strokeWidth={2.5} /> icon: <Layers className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
}, },
{ {
id: 'referenzen', id: 'referenzen',
label: navLinks[2]?.label || (currentLocale === 'de' ? 'Referenzen' : 'References'), label: navLinks[2]?.label || (currentLocale === 'de' ? 'Referenzen' : 'References'),
url: referencesUrl, url: referencesUrl,
icon: <Users className="w-5 h-5 md:w-6 md:h-6" strokeWidth={2.5} /> // Using Users for now, wait we need an image or star icon? icon: <Star className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
},
{
id: 'menu',
label: navLinks[1]?.label || (currentLocale === 'de' ? 'Über uns' : 'About Us'),
url: '#', // Triggers flyout
icon: <svg className="w-5 h-5 md:w-6 md:h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16m-7 6h7" /></svg>,
isFlyoutTrigger: true
}, },
{ {
id: 'kontakt', id: 'kontakt',
label: currentLocale === 'de' ? 'Kontakt' : 'Contact', label: currentLocale === 'de' ? 'Kontakt' : 'Contact',
url: contactUrl, url: contactUrl,
icon: <Mail className="w-5 h-5 md:w-6 md:h-6" strokeWidth={2.5} /> icon: <Mail className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
},
{
id: 'menu',
label: currentLocale === 'de' ? 'Mehr' : 'Menu',
url: '#',
icon: <Menu className="w-[24px] h-[24px] md:w-6 md:h-6" strokeWidth={2.5} />,
isFlyoutTrigger: true
} }
]; ];
}, [currentLocale, navLinks]); }, [currentLocale, navLinks]);
// Use a star or folder for references? Let's use standard Briefcase for Kompetenzen, and maybe a Layers or Image icon for Referenzen.
// We'll replace the Users icon with something better if possible, but Users is fine for now, wait, Users is Über uns!
// I will fix the icon in the render.
// Gather flyout links:
const flyoutLinks = React.useMemo(() => { const flyoutLinks = React.useMemo(() => {
// We want to put Über uns children, Karriere, and Messen in here.
const aboutNode = navLinks[1]; const aboutNode = navLinks[1];
const karriereNode = navLinks[3]; const karriereNode = navLinks[3];
const messenNode = navLinks[4]; const messenNode = navLinks[4];
@@ -90,10 +91,9 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
return ( return (
<> <>
<div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-4 pb-[calc(env(safe-area-inset-bottom)+1rem)] pt-4 pointer-events-none flex justify-center"> <div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-2 pb-[calc(env(safe-area-inset-bottom)+12px)] pt-4 pointer-events-none flex justify-center">
<nav className="flex justify-between items-center bg-white/80 backdrop-blur-3xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-3xl p-1.5 pointer-events-auto w-full max-w-[400px]"> <nav className="flex justify-between items-center bg-white/75 backdrop-blur-2xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-3xl p-1.5 pointer-events-auto w-full max-w-[480px]">
{items.map((item) => { {items.map((item) => {
// Determine if active
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/) const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
? `/${currentLocale}${item.url}` ? `/${currentLocale}${item.url}`
: item.url; : item.url;
@@ -107,18 +107,12 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`))); : (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
} }
// Fix icon for referenzen
let ItemIcon = item.icon;
if (item.id === 'referenzen') {
ItemIcon = <svg className="w-5 h-5 md:w-6 md:h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>;
}
const innerContent = ( const innerContent = (
<> <>
{isActive && ( {isActive && (
<motion.div <motion.div
layoutId="mobile-nav-active-bg" layoutId="mobile-nav-active-bg"
className="absolute inset-0 bg-primary rounded-2xl shadow-md" className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
transition={{ type: 'spring', stiffness: 400, damping: 25 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }}
/> />
)} )}
@@ -129,11 +123,11 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
animate={{ y: isActive ? -2 : 0 }} animate={{ y: isActive ? -2 : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }} transition={{ type: 'spring', stiffness: 400, damping: 17 }}
> >
{ItemIcon} {item.icon}
</motion.div> </motion.div>
<motion.span <motion.span
animate={{ opacity: isActive ? 1 : 0.7, y: isActive ? 0 : 2 }} animate={{ opacity: isActive ? 1 : 0.7, y: isActive ? 0 : 2 }}
className="text-[10px] font-bold mt-1 tracking-wide" className="text-[9px] font-bold mt-1 tracking-wide"
> >
{item.label} {item.label}
</motion.span> </motion.span>
@@ -146,8 +140,8 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
<button <button
key={item.label} key={item.label}
onClick={() => setIsFlyoutOpen(!isFlyoutOpen)} onClick={() => setIsFlyoutOpen(!isFlyoutOpen)}
className={`relative flex flex-col items-center justify-center flex-1 h-[60px] transition-colors duration-300 select-none w-full focus:outline-none ${ className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none w-full focus:outline-none ${
isActive ? 'text-white' : 'text-neutral-500 hover:text-neutral-900' isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
}`} }`}
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }} style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
> >
@@ -160,8 +154,9 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
<TransitionLink <TransitionLink
key={item.label} key={item.label}
href={mappedUrl} href={mappedUrl}
className={`relative flex flex-col items-center justify-center flex-1 h-[60px] transition-colors duration-300 select-none ${ onClick={() => setIsFlyoutOpen(false)}
isActive ? 'text-white' : 'text-neutral-500 hover:text-neutral-900' className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
}`} }`}
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }} style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
> >
@@ -172,7 +167,6 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
</nav> </nav>
</div> </div>
{/* Flyout Menu (Bottom Sheet) */}
<AnimatePresence> <AnimatePresence>
{isFlyoutOpen && ( {isFlyoutOpen && (
<> <>
@@ -188,11 +182,19 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
initial={{ y: '100%', opacity: 0 }} initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }} exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }} transition={{ type: 'spring', stiffness: 350, damping: 30 }}
className="fixed bottom-0 left-0 right-0 z-[9998] bg-white rounded-t-3xl pt-6 pb-[calc(env(safe-area-inset-bottom)+6rem)] px-6 md:hidden shadow-[0_-20px_40px_rgba(0,0,0,0.1)] border-t border-neutral-100" drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
if (info.offset.y > 50 || info.velocity.y > 100) {
setIsFlyoutOpen(false);
}
}}
className="fixed bottom-0 left-0 right-0 z-[9998] bg-white rounded-t-[32px] pt-4 pb-[calc(env(safe-area-inset-bottom)+7rem)] px-6 md:hidden shadow-[0_-20px_40px_rgba(0,0,0,0.1)] border-t border-neutral-100"
> >
<div className="w-12 h-1.5 bg-neutral-200 rounded-full mx-auto mb-6" /> <div className="w-16 h-1.5 bg-neutral-200 rounded-full mx-auto mb-6 cursor-grab active:cursor-grabbing" />
<h3 className="text-sm font-bold uppercase tracking-widest text-neutral-400 mb-4 px-2">{currentLocale === 'de' ? 'Über uns & Mehr' : 'About us & More'}</h3> <h3 className="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-4 px-2">{currentLocale === 'de' ? 'Über uns & Mehr' : 'About us & More'}</h3>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{flyoutLinks.map((link, i) => { {flyoutLinks.map((link, i) => {
const childUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/) const childUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
@@ -205,8 +207,8 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
key={i} key={i}
href={childUrl} href={childUrl}
onClick={() => setIsFlyoutOpen(false)} onClick={() => setIsFlyoutOpen(false)}
className={`px-4 py-3.5 rounded-2xl text-base font-medium transition-all flex items-center justify-between ${ className={`px-4 py-4 rounded-2xl text-[15px] font-semibold transition-all flex items-center justify-between ${
isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-700 hover:bg-neutral-50' isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-700 hover:bg-neutral-50 active:bg-neutral-100'
}`} }`}
> >
{link.label} {link.label}

View File

@@ -138,7 +138,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.13-rc.58", "version": "2.2.13-rc.59",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",