feat(i18n): redesign nav locale switcher from binary toggle to multi-locale dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,11 +115,20 @@ export default function Nav() {
|
|||||||
}
|
}
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
const otherLocale = locales.find((l) => l !== currentLocale) ?? 'fr'
|
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const LOCALE_DISPLAY: Record<string, { flag: string; label: string; short: string }> = {
|
||||||
|
en: { flag: '🇬🇧', label: 'English', short: 'EN' },
|
||||||
|
fr: { flag: '🇫🇷', label: 'Français', short: 'FR' },
|
||||||
|
it: { flag: '🇮🇹', label: 'Italiano', short: 'IT' },
|
||||||
|
es: { flag: '🇪🇸', label: 'Español', short: 'ES' },
|
||||||
|
}
|
||||||
|
|
||||||
const isHomePage = pathname === '/'
|
const isHomePage = pathname === '/'
|
||||||
|
|
||||||
function handleLocaleSwitch() {
|
function handleLocaleSwitch(targetLocale: string) {
|
||||||
router.replace(pathname as any, { locale: otherLocale as any })
|
router.replace(pathname as any, { locale: targetLocale as any })
|
||||||
|
setLocaleMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent body scroll when mobile menu is open
|
// Prevent body scroll when mobile menu is open
|
||||||
@@ -183,15 +192,59 @@ export default function Nav() {
|
|||||||
|
|
||||||
{/* ── Desktop right controls ── */}
|
{/* ── Desktop right controls ── */}
|
||||||
<div className="hidden lg:flex items-center gap-4 shrink-0">
|
<div className="hidden lg:flex items-center gap-4 shrink-0">
|
||||||
{/* Language toggle */}
|
{/* Language dropdown */}
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleLocaleSwitch}
|
onClick={() => setLocaleMenuOpen((prev) => !prev)}
|
||||||
className="label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 px-2 py-1 rounded focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
className="label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 px-2 py-1 rounded focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||||
aria-label={`Switch to ${otherLocale === 'fr' ? 'French' : 'English'}`}
|
aria-label="Switch language"
|
||||||
|
aria-expanded={localeMenuOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
{otherLocale.toUpperCase()}
|
{LOCALE_DISPLAY[currentLocale]?.short ?? currentLocale.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Transparent overlay to close on outside click */}
|
||||||
|
{localeMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[40]"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => setLocaleMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{localeMenuOpen && (
|
||||||
|
<motion.ul
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Select language"
|
||||||
|
className="absolute right-0 top-full mt-2 z-[41] bg-surface-high rounded-xl shadow-lg border border-outline-variant/20 overflow-hidden min-w-[140px]"
|
||||||
|
initial={{ opacity: 0, y: -6, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -6, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
|
>
|
||||||
|
{locales
|
||||||
|
.filter((l) => l !== currentLocale)
|
||||||
|
.map((l) => {
|
||||||
|
const display = LOCALE_DISPLAY[l]
|
||||||
|
return (
|
||||||
|
<li key={l} role="option" aria-selected={false}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLocaleSwitch(l)}
|
||||||
|
className="w-full flex items-center gap-2.5 px-4 py-2.5 label-md text-on-surface/70 hover:text-on-surface hover:bg-surface-low transition-colors duration-150 text-left"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{display?.flag}</span>
|
||||||
|
<span>{display?.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</motion.ul>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Start a Project CTA */}
|
{/* Start a Project CTA */}
|
||||||
<a
|
<a
|
||||||
href="#configure"
|
href="#configure"
|
||||||
@@ -207,7 +260,7 @@ export default function Nav() {
|
|||||||
{/* ── Mobile hamburger ── */}
|
{/* ── Mobile hamburger ── */}
|
||||||
<button
|
<button
|
||||||
className="lg:hidden p-2 -mr-2 text-on-surface focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
|
className="lg:hidden p-2 -mr-2 text-on-surface focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={() => { setMobileOpen(true); setLocaleMenuOpen(false) }}
|
||||||
aria-label="Open navigation menu"
|
aria-label="Open navigation menu"
|
||||||
aria-expanded={mobileOpen}
|
aria-expanded={mobileOpen}
|
||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
@@ -300,16 +353,27 @@ export default function Nav() {
|
|||||||
|
|
||||||
{/* Bottom controls */}
|
{/* Bottom controls */}
|
||||||
<div className="px-6 pb-8 pt-4 flex flex-col gap-3">
|
<div className="px-6 pb-8 pt-4 flex flex-col gap-3">
|
||||||
{/* Language toggle */}
|
{/* Language options */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{locales
|
||||||
|
.filter((l) => l !== currentLocale)
|
||||||
|
.map((l) => {
|
||||||
|
const display = LOCALE_DISPLAY[l]
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
|
key={l}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleLocaleSwitch()
|
handleLocaleSwitch(l)
|
||||||
setMobileOpen(false)
|
setMobileOpen(false)
|
||||||
}}
|
}}
|
||||||
className="w-full py-3 label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 text-left focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded"
|
className="w-full py-2.5 label-md text-on-surface/60 hover:text-on-surface transition-colors duration-200 text-left focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded flex items-center gap-2.5"
|
||||||
>
|
>
|
||||||
{otherLocale === 'fr' ? '🇫🇷 Français' : '🇬🇧 English'}
|
<span aria-hidden="true">{display?.flag}</span>
|
||||||
|
<span>{display?.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<a
|
<a
|
||||||
|
|||||||
Reference in New Issue
Block a user