diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index 8e155e0e267..49f50e79b7d 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -46,7 +46,7 @@ export default async function LocaleLayout({
// Enable static rendering
setRequestLocale(locale)
- const allMessages = await getMessages({ locale })
+ const allMessages = await getMessages()
const messages = pick(allMessages, "common")
const lastDeployDate = getLastDeployDate()
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 3c7fdd9d64a..231cff82a4a 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -157,6 +157,7 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => {
if (!LOCALES_CODES.includes(locale)) return notFound()
setRequestLocale(locale)
+
const t = await getTranslations({ locale, namespace: "page-index" })
const tCommon = await getTranslations({ locale, namespace: "common" })
const { direction: dir, isRtl } = getDirection(locale)
diff --git a/package.json b/package.json
index 8eba975ae9a..401ed5f7981 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.1",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-compose-refs": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a3d24c82daa..4804aa54230 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.1.1
version: 1.3.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-collapsible':
+ specifier: ^1.1.12
+ version: 1.1.12(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs':
specifier: ^1.1.0
version: 1.1.2(@types/react@18.2.57)(react@18.3.1)
@@ -2240,6 +2243,9 @@ packages:
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
'@radix-ui/react-accordion@1.2.11':
resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==}
peerDependencies:
@@ -2305,6 +2311,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-collapsible@1.1.12':
+ resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -2493,6 +2512,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
@@ -11625,6 +11657,8 @@ snapshots:
'@radix-ui/primitive@1.1.2': {}
+ '@radix-ui/primitive@1.1.3': {}
+
'@radix-ui/react-accordion@1.2.11(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -11696,6 +11730,22 @@ snapshots:
'@types/react': 18.2.57
'@types/react-dom': 18.2.19
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.2.57)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.2.57)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.57)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.57)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.2.57
+ '@types/react-dom': 18.2.19
+
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1)
@@ -11909,6 +11959,16 @@ snapshots:
'@types/react': 18.2.57
'@types/react-dom': 18.2.19
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.57)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.2.57
+ '@types/react-dom': 18.2.19
+
'@radix-ui/react-primitive@2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@18.2.57)(react@18.3.1)
diff --git a/src/components/ClientOnly.tsx b/src/components/ClientOnly.tsx
new file mode 100644
index 00000000000..f894f08a2d0
--- /dev/null
+++ b/src/components/ClientOnly.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+import { type ReactNode } from "react"
+
+import { useIsClient } from "@/hooks/useIsClient"
+
+type ClientOnlyProps = {
+ children: ReactNode
+ fallback?: ReactNode
+}
+
+const ClientOnly = ({ children, fallback = null }: ClientOnlyProps) => {
+ const isClient = useIsClient()
+
+ if (!isClient) return <>{fallback}>
+ return <>{children}>
+}
+
+export default ClientOnly
diff --git a/src/components/LanguagePicker/Desktop.tsx b/src/components/LanguagePicker/Desktop.tsx
new file mode 100644
index 00000000000..1bd783d2499
--- /dev/null
+++ b/src/components/LanguagePicker/Desktop.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import type { LocaleDisplayInfo } from "@/lib/types"
+
+import { cn } from "@/lib/utils/cn"
+
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"
+
+import LanguagePicker from "."
+
+import { useDisclosure } from "@/hooks/useDisclosure"
+import { useEventListener } from "@/hooks/useEventListener"
+
+type DesktopLanguagePickerProps = {
+ children: React.ReactNode
+ languages: LocaleDisplayInfo[]
+ className?: string
+}
+
+const DesktopLanguagePicker = ({
+ children,
+ languages,
+ className,
+}: DesktopLanguagePickerProps) => {
+ const { isOpen, setValue, onClose, onOpen } = useDisclosure()
+
+ /**
+ * Adds a keydown event listener to focus filter input (\).
+ * @param {string} event - The keydown event.
+ */
+ useEventListener("keydown", (e) => {
+ if (e.key !== "\\" || e.metaKey || e.ctrlKey) return
+ e.preventDefault()
+ onOpen()
+ })
+
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+export default DesktopLanguagePicker
diff --git a/src/components/LanguagePicker/LanguagePickerFooter.tsx b/src/components/LanguagePicker/LanguagePickerFooter.tsx
new file mode 100644
index 00000000000..0e707a1f981
--- /dev/null
+++ b/src/components/LanguagePicker/LanguagePickerFooter.tsx
@@ -0,0 +1,55 @@
+import { useLocale } from "next-intl"
+
+import type { LocaleDisplayInfo } from "@/lib/types"
+
+import { ButtonLink } from "@/components/ui/buttons/Button"
+
+import { DEFAULT_LOCALE } from "@/lib/constants"
+
+import { useTranslation } from "@/hooks/useTranslation"
+
+type LanguagePickerFooterProps = {
+ intlLanguagePreference?: LocaleDisplayInfo
+ onTranslationProgramClick: () => void
+}
+
+const LanguagePickerFooter = ({
+ intlLanguagePreference,
+ onTranslationProgramClick,
+}: LanguagePickerFooterProps) => {
+ const { t } = useTranslation("common")
+ const locale = useLocale()
+ return (
+
+
+
+ {locale === DEFAULT_LOCALE ? (
+
+ {intlLanguagePreference
+ ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}`
+ : "Translate ethereum.org"}
+
+ ) : (
+
+ {t("page-languages-translate-cta-title")}{" "}
+ {t(`language-${locale}`)}
+
+ )}
+
+ {t("page-languages-recruit-community")}
+
+
+
+ {t("get-involved")}
+
+
+
+ )
+}
+
+export default LanguagePickerFooter
diff --git a/src/components/LanguagePicker/LanguagePickerMenu.tsx b/src/components/LanguagePicker/LanguagePickerMenu.tsx
new file mode 100644
index 00000000000..62f2de291ca
--- /dev/null
+++ b/src/components/LanguagePicker/LanguagePickerMenu.tsx
@@ -0,0 +1,85 @@
+import type { LocaleDisplayInfo } from "@/lib/types"
+
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandList,
+} from "../ui/command"
+
+import MenuItem from "./MenuItem"
+import NoResultsCallout from "./NoResultsCallout"
+
+import { useTranslation } from "@/hooks/useTranslation"
+
+type LanguagePickerMenuProps = {
+ className?: string
+ languages: LocaleDisplayInfo[]
+ onClose: () => void
+ onSelect: (value: string) => void
+}
+
+const LanguagePickerMenu = ({
+ className,
+ languages,
+ onClose,
+ onSelect,
+}: LanguagePickerMenuProps) => {
+ const { t } = useTranslation("common")
+
+ return (
+ {
+ const item = languages.find((name) => name.localeOption === value)
+
+ if (!item) return 0
+
+ const { localeOption, sourceName, targetName, englishName } = item
+
+ if (
+ (localeOption + sourceName + targetName + englishName)
+ .toLowerCase()
+ .includes(search.toLowerCase())
+ ) {
+ return 1
+ }
+
+ return 0
+ }}
+ >
+
+ {t("page-languages-filter-label")}{" "}
+
+ ({languages.length} {t("common:languages")})
+
+
+
+
+
+
+
+
+
+
+ {languages.map((displayInfo) => (
+
+ ))}
+
+
+
+ )
+}
+
+export default LanguagePickerMenu
diff --git a/src/components/LanguagePicker/MenuItem.tsx b/src/components/LanguagePicker/MenuItem.tsx
index f75a59d025c..7ca3d3b34cc 100644
--- a/src/components/LanguagePicker/MenuItem.tsx
+++ b/src/components/LanguagePicker/MenuItem.tsx
@@ -22,24 +22,13 @@ const MenuItem = ({ displayInfo, ...props }: ItemProps) => {
sourceName,
targetName,
approvalProgress,
- wordsApproved,
+ progress,
+ words,
} = displayInfo
const { t } = useTranslation("common")
const locale = useLocale()
const isCurrent = localeOption === locale
- const getProgressInfo = (approvalProgress: number, wordsApproved: number) => {
- const percentage = new Intl.NumberFormat(locale!, {
- style: "percent",
- }).format(approvalProgress / 100)
- const progress =
- approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage
- const words = new Intl.NumberFormat(locale!).format(wordsApproved)
- return { progress, words }
- }
-
- const { progress, words } = getProgressInfo(approvalProgress, wordsApproved)
-
return (
-}
-
-export const MobileCloseBar = ({ handleClick }: MobileCloseBarProps) => {
- const { t } = useTranslation()
-
- return (
-
-
-
- )
-}
diff --git a/src/components/LanguagePicker/index.tsx b/src/components/LanguagePicker/index.tsx
index 354f071da94..f8f48529843 100644
--- a/src/components/LanguagePicker/index.tsx
+++ b/src/components/LanguagePicker/index.tsx
@@ -1,68 +1,59 @@
"use client"
+import { useEffect } from "react"
import { useParams } from "next/navigation"
-import { useLocale } from "next-intl"
import type { LocaleDisplayInfo } from "@/lib/types"
-import { ButtonLink } from "@/components/ui/buttons/Button"
-
import { cn } from "@/lib/utils/cn"
+import { trackCustomEvent } from "@/lib/utils/matomo"
-import { DEFAULT_LOCALE } from "@/lib/constants"
-
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandList,
-} from "../ui/command"
-import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"
-import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"
-
-import MenuItem from "./MenuItem"
-import { MobileCloseBar } from "./MobileCloseBar"
-import NoResultsCallout from "./NoResultsCallout"
+import LanguagePickerFooter from "./LanguagePickerFooter"
+import LanguagePickerMenu from "./LanguagePickerMenu"
import { useLanguagePicker } from "./useLanguagePicker"
-import { useEventListener } from "@/hooks/useEventListener"
-import { useTranslation } from "@/hooks/useTranslation"
import { usePathname, useRouter } from "@/i18n/routing"
type LanguagePickerProps = {
- children: React.ReactNode
className?: string
- handleClose?: () => void
- dialog?: boolean
+ languages: LocaleDisplayInfo[]
+ onSelect?: (value: string) => void
+ onNoResultsClose?: () => void
+ onTranslationProgramClick?: () => void
}
const LanguagePicker = ({
- children,
- handleClose,
+ languages,
className,
- dialog,
+ onSelect,
+ onNoResultsClose,
+ onTranslationProgramClick,
}: LanguagePickerProps) => {
const pathname = usePathname()
const { push } = useRouter()
const params = useParams()
- const { disclosure, languages, intlLanguagePreference } =
- useLanguagePicker(handleClose)
- const { isOpen, setValue, onClose, onOpen } = disclosure
-
- /**
- * Adds a keydown event listener to focus filter input (\).
- * @param {string} event - The keydown event.
- */
- useEventListener("keydown", (e) => {
- if (e.key !== "\\" || e.metaKey || e.ctrlKey) return
- e.preventDefault()
- onOpen()
- })
-
- // onClick handlers
- const handleMobileCloseBarClick = () => onClose()
+ const { languages: sortedLanguages, intlLanguagePreference } =
+ useLanguagePicker(languages)
+
+ useEffect(() => {
+ trackCustomEvent({
+ eventCategory: `Language picker`,
+ eventAction: "Open or close language picker",
+ eventName: "Opened",
+ })
+
+ return () => {
+ trackCustomEvent({
+ eventCategory: `Language picker`,
+ eventAction: "Open or close language picker",
+ eventName: "Closed",
+ })
+ }
+ }, [])
+
const handleMenuItemSelect = (currentValue: string) => {
+ onSelect?.(currentValue)
+
push(
// @ts-expect-error -- TypeScript will validate that only known `params`
// are used in combination with a given `pathname`. Since the two will
@@ -72,171 +63,47 @@ const LanguagePicker = ({
locale: currentValue,
}
)
- onClose({
+
+ trackCustomEvent({
+ eventCategory: `Language picker`,
eventAction: "Locale chosen",
eventName: currentValue,
})
}
- const handleBaseLinkClose = () =>
- onClose({
- eventAction: "Translation program link (menu footer)",
+
+ const handleNoResultsClose = () => {
+ onNoResultsClose?.()
+
+ trackCustomEvent({
+ eventCategory: `Language picker`,
+ eventAction: "Translation program link (no results)",
eventName: "/contributing/translation-program",
})
-
- if (dialog) {
- return (
-
- )
}
- return (
-
- {children}
-
-
- onClose({
- eventAction: "Translation program link (no results)",
- eventName: "/contributing/translation-program",
- })
- }
- />
-
-
-
-
- )
-}
+ const handleTranslationProgramClick = () => {
+ onTranslationProgramClick?.()
-const LanguagePickerMenu = ({ languages, onClose, onSelect }) => {
- const { t } = useTranslation("common")
+ trackCustomEvent({
+ eventCategory: `Language picker`,
+ eventAction: "Translation program link (menu footer)",
+ eventName: "/contributing/translation-program",
+ })
+ }
return (
- {
- const item = languages.find((name) => name.localeOption === value)
-
- if (!item) return 0
-
- const { localeOption, sourceName, targetName, englishName } = item
-
- if (
- (localeOption + sourceName + targetName + englishName)
- .toLowerCase()
- .includes(search.toLowerCase())
- ) {
- return 1
- }
-
- return 0
- }}
- >
-
- {t("page-languages-filter-label")}{" "}
-
- ({languages.length} {t("common:languages")})
-
-
-
-
+
-
-
-
-
-
- {languages.map((displayInfo) => (
-
- ))}
-
-
-
- )
-}
-
-const LanguagePickerFooter = ({
- intlLanguagePreference,
- onTranslationProgramClick,
-}: {
- intlLanguagePreference?: LocaleDisplayInfo
- onTranslationProgramClick: () => void
-}) => {
- const { t } = useTranslation("common")
- const locale = useLocale()
- console.log({ intlLanguagePreference })
- return (
-
-
-
- {locale === DEFAULT_LOCALE ? (
-
- {intlLanguagePreference
- ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}`
- : "Translate ethereum.org"}
-
- ) : (
-
- {t("page-languages-translate-cta-title")}{" "}
- {t(`language-${locale}`)}
-
- )}
-
- {t("page-languages-recruit-community")}
-
-
-
- {t("get-involved")}
-
-
+
)
}
diff --git a/src/components/LanguagePicker/useLanguagePicker.tsx b/src/components/LanguagePicker/useLanguagePicker.tsx
index 34da208be57..35f584d135c 100644
--- a/src/components/LanguagePicker/useLanguagePicker.tsx
+++ b/src/components/LanguagePicker/useLanguagePicker.tsx
@@ -3,22 +3,16 @@ import { useLocale } from "next-intl"
import type { Lang, LocaleDisplayInfo } from "@/lib/types"
-import { MatomoEventOptions, trackCustomEvent } from "@/lib/utils/matomo"
import { filterRealLocales } from "@/lib/utils/translations"
import { LOCALES_CODES } from "@/lib/constants"
-import { localeToDisplayInfo } from "./localeToDisplayInfo"
+// Move locales computation outside component to make it stable
+const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES)
-import { useDisclosure } from "@/hooks/useDisclosure"
-import { useTranslation } from "@/hooks/useTranslation"
-
-export const useLanguagePicker = (handleClose?: () => void) => {
- const { t } = useTranslation("common")
+export const useLanguagePicker = (languages: LocaleDisplayInfo[]) => {
const locale = useLocale()
- const locales = useMemo(() => filterRealLocales(LOCALES_CODES), [])
-
// Find all matching browser language preferences in order
const intlLocalePreferences = useMemo(() => {
// Get the preferred languages for the users browser
@@ -26,7 +20,7 @@ export const useLanguagePicker = (handleClose?: () => void) => {
const preferences: Lang[] = []
for (const navLang of navLangs) {
- const match = locales?.find((locale) => {
+ const match = FILTERED_LOCALES?.find((locale) => {
// Exact match first
if (locale.toLowerCase() === navLang.toLowerCase()) return true
// Then partial match (e.g., 'en-US' matches 'en')
@@ -40,90 +34,51 @@ export const useLanguagePicker = (handleClose?: () => void) => {
}
return preferences
- }, [locales])
+ }, [])
// Keep the first preference for backward compatibility
const intlLocalePreference = intlLocalePreferences[0] || ""
- const languages = useMemo(
- () =>
- (locales as Lang[])
- ?.map((localeOption) => {
- const displayInfo = localeToDisplayInfo(
- localeOption,
- locale as Lang,
- t
- )
- const isBrowserDefault = intlLocalePreferences.includes(
- localeOption as Lang
- )
- return {
- ...displayInfo,
- isBrowserDefault,
- }
- })
- .sort((a, b) => {
- const aPreferenceIndex = intlLocalePreferences.indexOf(
- a.localeOption as Lang
- )
- const bPreferenceIndex = intlLocalePreferences.indexOf(
- b.localeOption as Lang
- )
-
- // First, sort by browser preferences (all browser preferences come first)
- if (a.isBrowserDefault && !b.isBrowserDefault) return -1
- if (!a.isBrowserDefault && b.isBrowserDefault) return 1
-
- // If both are browser preferences, sort by preference order
- if (a.isBrowserDefault && b.isBrowserDefault) {
- return aPreferenceIndex - bPreferenceIndex
- }
-
- // Otherwise, sort alphabetically by source name using localeCompare
- return a.sourceName.localeCompare(b.sourceName, locale)
- }) || [],
- [intlLocalePreferences, locale, locales, t]
- )
-
- const intlLanguagePreference = languages.find(
+ // Sort languages client-side to prioritize browser preference
+ const sortedLanguages = useMemo(() => {
+ return [...languages]
+ .map((displayInfo) => {
+ const isBrowserDefault = intlLocalePreferences.includes(
+ displayInfo.localeOption as Lang
+ )
+ return {
+ ...displayInfo,
+ isBrowserDefault,
+ }
+ })
+ .sort((a, b) => {
+ const aPreferenceIndex = intlLocalePreferences.indexOf(
+ a.localeOption as Lang
+ )
+ const bPreferenceIndex = intlLocalePreferences.indexOf(
+ b.localeOption as Lang
+ )
+
+ // First, sort by browser preferences (all browser preferences come first)
+ if (a.isBrowserDefault && !b.isBrowserDefault) return -1
+ if (!a.isBrowserDefault && b.isBrowserDefault) return 1
+
+ // If both are browser preferences, sort by preference order
+ if (a.isBrowserDefault && b.isBrowserDefault) {
+ return aPreferenceIndex - bPreferenceIndex
+ }
+
+ // Otherwise, sort alphabetically by source name using localeCompare
+ return a.sourceName.localeCompare(b.sourceName, locale)
+ })
+ }, [languages, intlLocalePreferences, locale])
+
+ const intlLanguagePreference = sortedLanguages.find(
(lang) => lang.localeOption === intlLocalePreference
)
- const { isOpen, setValue, ...menu } = useDisclosure()
-
- const eventBase: Pick = {
- eventCategory: `Language picker`,
- eventAction: "Open or close language picker",
- }
-
- const onOpen = () => {
- menu.onOpen()
- trackCustomEvent({
- ...eventBase,
- eventName: "Opened",
- } as MatomoEventOptions)
- }
-
- /**
- * When closing the menu, track whether this is following a link, or simply closing the menu
- * @param customMatomoEvent Optional custom event property overrides
- */
- const onClose = (
- customMatomoEvent?: Required> &
- Partial
- ): void => {
- handleClose && handleClose()
- menu.onClose()
- trackCustomEvent(
- (customMatomoEvent
- ? { ...eventBase, ...customMatomoEvent }
- : { ...eventBase, eventName: "Closed" }) satisfies MatomoEventOptions
- )
- }
-
return {
- disclosure: { isOpen, setValue, onOpen, onClose },
- languages,
+ languages: sortedLanguages,
intlLanguagePreference,
}
}
diff --git a/src/components/MediaQuery.tsx b/src/components/MediaQuery.tsx
new file mode 100644
index 00000000000..ad31f265323
--- /dev/null
+++ b/src/components/MediaQuery.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import { type ReactNode } from "react"
+
+import { useMediaQuery } from "@/hooks/useMediaQuery"
+
+type MediaQueryChildren =
+ | ((args: { matches: boolean[]; any: boolean }) => ReactNode)
+ | ReactNode
+
+type MediaQueryProps = {
+ /**
+ * Array of CSS media query strings, e.g. ["(min-width: 768px)"]
+ */
+ queries: string[]
+ /**
+ * Optional SSR fallbacks for each query. If omitted, defaults to false.
+ */
+ fallbackMatches?: boolean[]
+ /**
+ * Either render props or slot children
+ */
+ children: MediaQueryChildren
+}
+
+const MediaQuery = ({
+ queries,
+ fallbackMatches,
+ children,
+}: MediaQueryProps) => {
+ const matches = useMediaQuery(queries, fallbackMatches)
+ const any = matches.some(Boolean)
+
+ if (typeof children === "function") {
+ return <>{children({ matches, any })}>
+ }
+
+ return <>{any ? children : null}>
+}
+
+export default MediaQuery
diff --git a/src/components/Nav/Client/index.tsx b/src/components/Nav/Client/index.tsx
deleted file mode 100644
index fd1409ed661..00000000000
--- a/src/components/Nav/Client/index.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-"use client"
-
-import { useRef } from "react"
-import { Languages } from "lucide-react"
-import dynamic from "next/dynamic"
-import { useLocale } from "next-intl"
-
-import SearchButton from "@/components/Search/SearchButton"
-import SearchInputButton from "@/components/Search/SearchInputButton"
-import { Skeleton } from "@/components/ui/skeleton"
-
-import { DESKTOP_LANGUAGE_BUTTON_NAME } from "@/lib/constants"
-
-import { Button } from "../../ui/buttons/Button"
-import { useNavigation } from "../useNavigation"
-import { useThemeToggle } from "../useThemeToggle"
-
-import { useBreakpointValue } from "@/hooks/useBreakpointValue"
-import { useIsClient } from "@/hooks/useIsClient"
-import { useTranslation } from "@/hooks/useTranslation"
-
-const LazyButton = dynamic(
- () => import("../../ui/buttons/Button").then((mod) => mod.Button),
- {
- ssr: false,
- loading: () => (
-
- ),
- }
-)
-
-const Menu = dynamic(() => import("../Menu"), {
- ssr: false,
- loading: () => (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- ),
-})
-
-const MobileMenuLoading = () => (
-
-)
-
-const MobileNavMenu = dynamic(() => import("../Mobile"), {
- ssr: false,
- loading: MobileMenuLoading,
-})
-
-const SearchProvider = dynamic(() => import("../../Search"), {
- ssr: false,
- loading: () => (
- <>
-
-
-
-
-
-
-
-
- >
- ),
-})
-
-const LanguagePicker = dynamic(() => import("../../LanguagePicker"), {
- ssr: false,
- loading: () => (
- // LG skeleton width approximates English "[icon] Languages EN" text width
-
- ),
-})
-
-const ClientSideNav = () => {
- const { t } = useTranslation("common")
- const locale = useLocale()
-
- const { linkSections } = useNavigation()
- const { toggleColorMode, ThemeIcon, themeIconAriaLabel } = useThemeToggle()
-
- const languagePickerRef = useRef(null)
- const isClient = useIsClient()
-
- // avoid rendering/adding desktop Menu version to DOM on mobile
- const desktopScreenValue = useBreakpointValue({ base: false, md: true })
-
- // Use fallback value during SSR to prevent hydration mismatch
- // Default to false (mobile) during SSR, then use actual value on client
- const desktopScreen = isClient && desktopScreenValue
-
- return (
- <>
- {desktopScreen && (
-
- )}
-
-
-
- {({ onOpen }) => {
- return (
- <>
-
-
-
- {!desktopScreen && (
-
- )}
- >
- )
- }}
-
-
- {desktopScreen && (
-
-
-
- )}
-
- {desktopScreen && (
-
-
-
- )}
-
- >
- )
-}
-
-export default ClientSideNav
diff --git a/src/components/Nav/DesktopNav.tsx b/src/components/Nav/DesktopNav.tsx
new file mode 100644
index 00000000000..c3d279c971a
--- /dev/null
+++ b/src/components/Nav/DesktopNav.tsx
@@ -0,0 +1,51 @@
+import { Languages } from "lucide-react"
+import { getLocale, getTranslations } from "next-intl/server"
+
+import { cn } from "@/lib/utils/cn"
+
+import { DESKTOP_LANGUAGE_BUTTON_NAME } from "@/lib/constants"
+
+import DesktopLanguagePicker from "../LanguagePicker/Desktop"
+import Search from "../Search"
+import { Button } from "../ui/buttons/Button"
+
+import Menu from "./Menu"
+import { ThemeToggleButton } from "./ThemeToggleButton"
+
+import { getLanguagesDisplayInfo } from "@/lib/nav/links"
+
+const DesktopNav = async ({ className }: { className?: string }) => {
+ const t = await getTranslations({ namespace: "common" })
+ const languages = await getLanguagesDisplayInfo()
+
+ const locale = await getLocale()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default DesktopNav
diff --git a/src/components/Nav/Menu/index.tsx b/src/components/Nav/Menu/index.tsx
index 61e9bd0da45..abfdb01b7b8 100644
--- a/src/components/Nav/Menu/index.tsx
+++ b/src/components/Nav/Menu/index.tsx
@@ -15,18 +15,17 @@ import { cn } from "@/lib/utils/cn"
import { MAIN_NAV_ID, SECTION_LABELS } from "@/lib/constants"
import { Button } from "../../ui/buttons/Button"
-import type { NavSections } from "../types"
+import { useNavigation } from "../useNavigation"
import MenuContent from "./MenuContent"
import { useNavMenu } from "./useNavMenu"
-type NavMenuProps = BaseHTMLAttributes & {
- sections: NavSections
-}
+type NavMenuProps = BaseHTMLAttributes
-const Menu = ({ sections, ...props }: NavMenuProps) => {
+const Menu = ({ ...props }: NavMenuProps) => {
+ const { linkSections } = useNavigation()
const { activeSection, direction, handleSectionChange, isOpen } =
- useNavMenu(sections)
+ useNavMenu(linkSections)
return (
@@ -38,7 +37,7 @@ const Menu = ({ sections, ...props }: NavMenuProps) => {
>
{SECTION_LABELS.map((sectionKey) => {
- const { label, items } = sections[sectionKey]
+ const { label, items } = linkSections[sectionKey]
const isActive = activeSection === sectionKey
return (
@@ -66,7 +65,7 @@ const Menu = ({ sections, ...props }: NavMenuProps) => {
)
diff --git a/src/components/Nav/Mobile/ExpandIcon.tsx b/src/components/Nav/Mobile/ExpandIcon.tsx
deleted file mode 100644
index bceaa6f343f..00000000000
--- a/src/components/Nav/Mobile/ExpandIcon.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Minus, Plus } from "lucide-react"
-
-type ExpandIconProps = {
- isOpen: boolean
-}
-
-const ExpandIcon = ({ isOpen }: ExpandIconProps) =>
- isOpen ? (
-
- ) : (
-
- )
-
-export default ExpandIcon
diff --git a/src/components/Nav/Mobile/LvlAccordion.tsx b/src/components/Nav/Mobile/LvlAccordion.tsx
deleted file mode 100644
index e03ea6ff7aa..00000000000
--- a/src/components/Nav/Mobile/LvlAccordion.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { useState } from "react"
-import { useLocale } from "next-intl"
-import * as AccordionPrimitive from "@radix-ui/react-accordion"
-
-import { cn } from "@/lib/utils/cn"
-import { trackCustomEvent } from "@/lib/utils/matomo"
-import { slugify } from "@/lib/utils/url"
-import { cleanPath } from "@/lib/utils/url"
-
-import { Button } from "../../ui/buttons/Button"
-import { BaseLink } from "../../ui/Link"
-import type { Level, NavItem, NavSectionKey } from "../types"
-
-import ExpandIcon from "./ExpandIcon"
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "./MenuAccordion"
-
-import { usePathname } from "@/i18n/routing"
-
-type LvlAccordionProps = {
- lvl: Level
- items: NavItem[]
- activeSection: NavSectionKey
- onToggle: () => void
-}
-
-const subtextColorPerLevel = {
- 1: "text-menu-1-subtext",
- 2: "text-menu-2-subtext",
- 3: "text-menu-3-subtext",
- 4: "text-menu-4-subtext",
-}
-
-const backgroundColorPerLevel = {
- 1: "bg-background",
- 2: "bg-background-low",
- 3: "bg-background-medium",
- 4: "bg-background-high",
-}
-
-const LvlAccordion = ({
- lvl,
- items,
- activeSection,
- onToggle,
-}: LvlAccordionProps) => {
- const pathname = usePathname()
- const locale = useLocale()
- const [value, setValue] = useState("")
-
- return (
-
- {items.map(({ label, description, ...action }) => {
- const isLink = "href" in action
- const isActivePage = isLink && cleanPath(pathname) === action.href
- const isExpanded = value === label
-
- const nestedAccordionSpacingMap = {
- 2: "ps-8",
- 3: "ps-12",
- 4: "ps-16",
- 5: "ps-20",
- 6: "ps-24",
- }
-
- if (isLink)
- return (
-
-
- {/* TODO: replace this with ButtonLink when is implemented */}
-
-
-
- )
-
- return (
-
- {
- trackCustomEvent({
- eventCategory: "Mobile navigation menu",
- eventAction: `Level ${lvl - 1} section changed`,
- eventName: `${
- isExpanded ? "Close" : "Open"
- } section: ${label} - ${description.slice(0, 16)}...`,
- })
- }}
- >
-
-
-
- {label}
-
-
- {description}
-
-
-
-
-
-
-
-
- )
- })}
-
- )
-}
-
-export default LvlAccordion
diff --git a/src/components/Nav/Mobile/MenuBody.tsx b/src/components/Nav/Mobile/MenuBody.tsx
deleted file mode 100644
index 24f4d2f89ad..00000000000
--- a/src/components/Nav/Mobile/MenuBody.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { useState } from "react"
-import { useLocale } from "next-intl"
-
-import { cn } from "@/lib/utils/cn"
-import { trackCustomEvent } from "@/lib/utils/matomo"
-import { slugify } from "@/lib/utils/url"
-
-import { SECTION_LABELS } from "@/lib/constants"
-
-import type { Level, NavSections } from "../types"
-
-import ExpandIcon from "./ExpandIcon"
-import LvlAccordion from "./LvlAccordion"
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "./MenuAccordion"
-
-type MenuBodyProps = {
- onToggle: () => void
- linkSections: NavSections
-}
-
-const MenuBody = ({ linkSections, onToggle }: MenuBodyProps) => {
- const locale = useLocale()
- const [value, setValue] = useState("")
-
- return (
-
- )
-}
-
-export default MenuBody
diff --git a/src/components/Nav/Mobile/MenuFooter.tsx b/src/components/Nav/Mobile/MenuFooter.tsx
deleted file mode 100644
index 28e011d8e8a..00000000000
--- a/src/components/Nav/Mobile/MenuFooter.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Languages, Moon, Search, Sun } from "lucide-react"
-
-import LanguagePicker from "@/components/LanguagePicker"
-
-import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants"
-
-import FooterButton from "./FooterButton"
-import FooterItemText from "./FooterItemText"
-
-import useColorModeValue from "@/hooks/useColorModeValue"
-import { useTranslation } from "@/hooks/useTranslation"
-
-type MenuFooterProps = {
- onToggle: () => void
- toggleColorMode: () => void
- toggleSearch: () => void
-}
-
-const MenuFooter = ({
- onToggle,
- toggleColorMode,
- toggleSearch,
-}: MenuFooterProps) => {
- const { t } = useTranslation("common")
- const ThemeIcon = useColorModeValue(Moon, Sun)
- const themeLabelKey = useColorModeValue("dark-mode", "light-mode")
-
- return (
-
- {
- // Workaround to ensure the input for the search modal can have focus
- onToggle()
- toggleSearch()
- }}
- >
- {t("search")}
-
-
-
- {t(themeLabelKey)}
-
-
-
-
- {t("languages")}
-
-
-
- )
-}
-
-export default MenuFooter
diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx
deleted file mode 100644
index e151d082006..00000000000
--- a/src/components/Nav/Mobile/index.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client"
-
-import {
- Sheet,
- SheetContent,
- SheetFooter,
- SheetHeader,
- SheetTrigger,
-} from "@/components/ui/sheet"
-
-import { cn } from "@/lib/utils/cn"
-
-import { ButtonProps } from "../../ui/buttons/Button"
-import type { NavSections } from "../types"
-
-import HamburgerButton from "./HamburgerButton"
-import MenuBody from "./MenuBody"
-import MenuFooter from "./MenuFooter"
-import MenuHeader from "./MenuHeader"
-
-import { useDisclosure } from "@/hooks/useDisclosure"
-
-type MobileNavMenuProps = ButtonProps & {
- toggleColorMode: () => void
- toggleSearch: () => void
- linkSections: NavSections
-}
-
-const MobileNavMenu = ({
- toggleColorMode,
- toggleSearch,
- linkSections,
- className,
- ...props
-}: MobileNavMenuProps) => {
- const { isOpen, onToggle } = useDisclosure()
-
- // DRAWER MENU
- return (
-
-
-
-
-
- {/* HEADER ELEMENTS: SITE NAME, CLOSE BUTTON */}
-
-
-
-
- {/* MAIN NAV ACCORDION CONTENTS OF MOBILE MENU */}
-
-
-
-
- {/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */}
-
-
-
-
-
- )
-}
-
-export default MobileNavMenu
diff --git a/src/components/Nav/MobileMenu/ExpandIcon.tsx b/src/components/Nav/MobileMenu/ExpandIcon.tsx
new file mode 100644
index 00000000000..2303355ac7e
--- /dev/null
+++ b/src/components/Nav/MobileMenu/ExpandIcon.tsx
@@ -0,0 +1,11 @@
+import { Minus, Plus } from "lucide-react"
+
+const ExpandIcon = () => (
+ <>
+
+
+
+ >
+)
+
+export default ExpandIcon
diff --git a/src/components/Nav/Mobile/FooterButton.tsx b/src/components/Nav/MobileMenu/FooterButton.tsx
similarity index 79%
rename from src/components/Nav/Mobile/FooterButton.tsx
rename to src/components/Nav/MobileMenu/FooterButton.tsx
index 9d182cc40ca..78f27f90205 100644
--- a/src/components/Nav/Mobile/FooterButton.tsx
+++ b/src/components/Nav/MobileMenu/FooterButton.tsx
@@ -4,18 +4,18 @@ import { LucideIcon } from "lucide-react"
import { Button, type ButtonProps } from "../../ui/buttons/Button"
type FooterButtonProps = ButtonProps & {
- icon: React.FC> | LucideIcon
+ icon?: React.FC> | LucideIcon
}
const FooterButton = forwardRef(
({ icon: Icon, children, ...props }, ref) => (
)
diff --git a/src/components/Nav/Mobile/FooterItemText.tsx b/src/components/Nav/MobileMenu/FooterItemText.tsx
similarity index 100%
rename from src/components/Nav/Mobile/FooterItemText.tsx
rename to src/components/Nav/MobileMenu/FooterItemText.tsx
diff --git a/src/components/Nav/Mobile/HamburgerButton.tsx b/src/components/Nav/MobileMenu/HamburgerButton.tsx
similarity index 99%
rename from src/components/Nav/Mobile/HamburgerButton.tsx
rename to src/components/Nav/MobileMenu/HamburgerButton.tsx
index 90ec5d53834..b0d5e695ccf 100644
--- a/src/components/Nav/Mobile/HamburgerButton.tsx
+++ b/src/components/Nav/MobileMenu/HamburgerButton.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { forwardRef } from "react"
import { motion } from "framer-motion"
diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx
new file mode 100644
index 00000000000..c26d7621d45
--- /dev/null
+++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx
@@ -0,0 +1,159 @@
+import {
+ CollapsibleContent,
+ CollapsibleTracked,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+
+import { cn } from "@/lib/utils/cn"
+import { slugify } from "@/lib/utils/url"
+
+import { Button } from "../../ui/buttons/Button"
+import { BaseLink } from "../../ui/Link"
+import type { Level, NavItem, NavSectionKey } from "../types"
+
+import ExpandIcon from "./ExpandIcon"
+
+type LvlAccordionProps = {
+ lvl: Level
+ items: NavItem[]
+ activeSection: NavSectionKey
+ locale: string
+}
+
+const subtextColorPerLevel = {
+ 1: "text-menu-1-subtext",
+ 2: "text-menu-2-subtext",
+ 3: "text-menu-3-subtext",
+ 4: "text-menu-4-subtext",
+}
+
+const backgroundColorPerLevel = {
+ 1: "bg-background",
+ 2: "bg-background-low",
+ 3: "bg-background-medium",
+ 4: "bg-background-high",
+}
+
+const nestedAccordionSpacingMap = {
+ 2: "ps-8",
+ 3: "ps-12",
+ 4: "ps-16",
+ 5: "ps-20",
+ 6: "ps-24",
+}
+
+const LvlAccordion = async ({
+ lvl,
+ items,
+ activeSection,
+ locale,
+}: LvlAccordionProps) => {
+ return (
+ <>
+ {items.map(({ label, description, ...action }) => {
+ const isLink = "href" in action
+
+ if (isLink)
+ return (
+
+
+
+ )
+
+ return (
+
+ svg]:rotate-90 [&[data-state=open]_[data-label=icon-container]>svg]:-rotate-90",
+ "flex h-full justify-start whitespace-normal px-4 py-4 text-start text-body no-underline",
+ "text-body",
+ nestedAccordionSpacingMap[lvl]
+ )}
+ >
+
+
+
+ {label}
+
+
+ {description}
+
+
+
+
+
+
+
+
+ )
+ })}
+ >
+ )
+}
+
+export default LvlAccordion
diff --git a/src/components/Nav/Mobile/MenuAccordion.tsx b/src/components/Nav/MobileMenu/MenuAccordion.tsx
similarity index 51%
rename from src/components/Nav/Mobile/MenuAccordion.tsx
rename to src/components/Nav/MobileMenu/MenuAccordion.tsx
index f9a3ef3db3a..970569f9ca9 100644
--- a/src/components/Nav/Mobile/MenuAccordion.tsx
+++ b/src/components/Nav/MobileMenu/MenuAccordion.tsx
@@ -1,8 +1,54 @@
+"use client"
+
+import { useState } from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { Lang } from "@/lib/types"
+
import { cn } from "@/lib/utils/cn"
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+import {
+ Accordion as BaseAccordion,
+ AccordionContent,
+ AccordionItem,
+} from "../../ui/accordion"
+
+type AccordionProps = {
+ locale: Lang
+ children: React.ReactNode
+}
+
+const Accordion = ({ locale, children }: AccordionProps) => {
+ const [currentValue, setCurrentValue] = useState(
+ undefined
+ )
-import { Accordion, AccordionContent, AccordionItem } from "../../ui/accordion"
+ const handleValueChange = (value: string | undefined) => {
+ const isExpanded = currentValue === value
+
+ trackCustomEvent({
+ eventCategory: "Mobile navigation menu",
+ eventAction: "Section changed",
+ eventName: `${
+ isExpanded ? "Close" : "Open"
+ } section: ${locale} - ${value || currentValue}`,
+ })
+
+ setCurrentValue(value)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
type AccordionTriggerProps = {
heading?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
diff --git a/src/components/Nav/Mobile/MenuHeader.tsx b/src/components/Nav/MobileMenu/MenuHeader.tsx
similarity index 100%
rename from src/components/Nav/Mobile/MenuHeader.tsx
rename to src/components/Nav/MobileMenu/MenuHeader.tsx
diff --git a/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx b/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx
new file mode 100644
index 00000000000..5e1e77805bd
--- /dev/null
+++ b/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import { Moon, Sun } from "lucide-react"
+
+import { useThemeToggle } from "../useThemeToggle"
+
+import FooterButton from "./FooterButton"
+import FooterItemText from "./FooterItemText"
+
+import useColorModeValue from "@/hooks/useColorModeValue"
+import { useTranslation } from "@/hooks/useTranslation"
+
+const ThemeToggleFooterButton = () => {
+ const { t } = useTranslation("common")
+ const ThemeIcon = useColorModeValue(Moon, Sun)
+ const themeLabelKey = useColorModeValue("dark-mode", "light-mode")
+ const { toggleColorMode } = useThemeToggle()
+
+ return (
+
+ {t(themeLabelKey)}
+
+ )
+}
+
+export default ThemeToggleFooterButton
diff --git a/src/components/Nav/MobileMenu/TrackedCollapsible.tsx b/src/components/Nav/MobileMenu/TrackedCollapsible.tsx
new file mode 100644
index 00000000000..a3f4ed09120
--- /dev/null
+++ b/src/components/Nav/MobileMenu/TrackedCollapsible.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import { PropsWithChildren, useCallback } from "react"
+
+import { Collapsible } from "@/components/ui/collapsible"
+
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+type TrackedCollapsibleProps = PropsWithChildren<{
+ className?: string
+ eventCategory: string
+ eventAction: string
+ openEventName: string
+ closeEventName: string
+}>
+
+export default function TrackedCollapsible({
+ className,
+ children,
+ eventCategory,
+ eventAction,
+ openEventName,
+ closeEventName,
+}: TrackedCollapsibleProps) {
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ trackCustomEvent({
+ eventCategory,
+ eventAction,
+ eventName: open ? openEventName : closeEventName,
+ })
+ },
+ [eventCategory, eventAction, openEventName, closeEventName]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx
new file mode 100644
index 00000000000..89e52a2d279
--- /dev/null
+++ b/src/components/Nav/MobileMenu/index.tsx
@@ -0,0 +1,174 @@
+import { Languages, Menu } from "lucide-react"
+import { getLocale, getTranslations } from "next-intl/server"
+import { Trigger as TabsTrigger } from "@radix-ui/react-tabs"
+
+import type { Lang } from "@/lib/types"
+
+import LanguagePicker from "@/components/LanguagePicker"
+import ExpandIcon from "@/components/Nav/MobileMenu/ExpandIcon"
+import LvlAccordion from "@/components/Nav/MobileMenu/LvlAccordion"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import {
+ SheetContent,
+ SheetFooter,
+ SheetHeader,
+ SheetTrigger,
+} from "@/components/ui/sheet"
+import { SheetCloseOnNavigate } from "@/components/ui/sheet-close-on-navigate"
+import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs"
+
+import { cn } from "@/lib/utils/cn"
+import { isLangRightToLeft } from "@/lib/utils/translations"
+import { slugify } from "@/lib/utils/url"
+
+import { MOBILE_LANGUAGE_BUTTON_NAME, SECTION_LABELS } from "@/lib/constants"
+
+import FooterButton from "./FooterButton"
+import FooterItemText from "./FooterItemText"
+import HamburgerButton from "./HamburgerButton"
+import MenuHeader from "./MenuHeader"
+import ThemeToggleFooterButton from "./ThemeToggleFooterButton"
+
+import { getLanguagesDisplayInfo, getNavigation } from "@/lib/nav/links"
+
+type MobileMenuProps = {
+ className?: string
+}
+
+export default async function MobileMenu({
+ className,
+ ...props
+}: MobileMenuProps) {
+ const t = await getTranslations({ namespace: "common" })
+ const locale = await getLocale()
+ const isRtl = isLangRightToLeft(locale as Lang)
+ const side = isRtl ? "right" : "left"
+ const dir = isRtl ? "rtl" : "ltr"
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("languages")}
+
+
+
+
+
+
+
+
+
+ {t("menu")}
+
+
+
+
+
+
+
+
+ )
+}
+
+async function NavigationContent({ className }: { className?: string }) {
+ const locale = await getLocale()
+ const linkSections = await getNavigation()
+
+ return (
+
+ )
+}
+
+async function LanguageContent({ className }: { className?: string }) {
+ const languages = await getLanguagesDisplayInfo()
+
+ return
+}
diff --git a/src/components/Nav/MobileNav.tsx b/src/components/Nav/MobileNav.tsx
new file mode 100644
index 00000000000..2557047ba0c
--- /dev/null
+++ b/src/components/Nav/MobileNav.tsx
@@ -0,0 +1,16 @@
+import { cn } from "@/lib/utils/cn"
+
+import Search from "../Search"
+
+import MobileMenu from "./MobileMenu"
+
+const MobileNav = ({ className }: { className?: string }) => {
+ return (
+
+
+
+
+ )
+}
+
+export default MobileNav
diff --git a/src/components/Nav/ThemeToggleButton.tsx b/src/components/Nav/ThemeToggleButton.tsx
new file mode 100644
index 00000000000..f2072c33ef3
--- /dev/null
+++ b/src/components/Nav/ThemeToggleButton.tsx
@@ -0,0 +1,21 @@
+"use client"
+
+import { Button } from "../ui/buttons/Button"
+
+import { useThemeToggle } from "./useThemeToggle"
+
+export const ThemeToggleButton = () => {
+ const { toggleColorMode, ThemeIcon, themeIconAriaLabel } = useThemeToggle()
+
+ return (
+
+ )
+}
diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx
index 62cc661bd72..011110fc494 100644
--- a/src/components/Nav/index.tsx
+++ b/src/components/Nav/index.tsx
@@ -1,14 +1,19 @@
-import { getLocale, getTranslations } from "next-intl/server"
+import { getTranslations } from "next-intl/server"
import { EthHomeIcon } from "@/components/icons"
+import { breakpointAsNumber } from "@/lib/utils/screen"
+
+import ClientOnly from "../ClientOnly"
+import MediaQuery from "../MediaQuery"
import { BaseLink } from "../ui/Link"
-import ClientSideNav from "./Client"
+import DesktopNav from "./DesktopNav"
+import { DesktopNavLoading, MobileNavLoading } from "./loading"
+import MobileNav from "./MobileNav"
const Nav = async () => {
- const locale = await getLocale()
- const t = await getTranslations({ locale, namespace: "common" })
+ const t = await getTranslations({ namespace: "common" })
return (
)
diff --git a/src/components/Nav/loading.tsx b/src/components/Nav/loading.tsx
new file mode 100644
index 00000000000..88f5c8a11e0
--- /dev/null
+++ b/src/components/Nav/loading.tsx
@@ -0,0 +1,29 @@
+import { Skeleton } from "@/components/ui/skeleton"
+
+export const DesktopNavLoading = () => {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+export const MobileNavLoading = () => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/Nav/types.ts b/src/components/Nav/types.ts
index d3dd7254ad1..94fc1014ca6 100644
--- a/src/components/Nav/types.ts
+++ b/src/components/Nav/types.ts
@@ -6,6 +6,7 @@ type ItemsOnly = { items: NavItem[]; href?: never }
type LinkXorItems = LinkOnly | ItemsOnly
export type NavItem = {
+ id?: string
label: string
description: string
icon?: LucideIcon | FC>
diff --git a/src/components/Nav/useNavigation.ts b/src/components/Nav/useNavigation.ts
index c939c59d715..d0a469bdec6 100644
--- a/src/components/Nav/useNavigation.ts
+++ b/src/components/Nav/useNavigation.ts
@@ -17,477 +17,72 @@ import SlidersHorizontalCircles from "@/components/icons/sliders-horizontal-circ
import UiChecksGridIcon from "@/components/icons/ui-checks-grid.svg"
import UsersFourLight from "@/components/icons/users-four-light.svg"
-import type { NavSections } from "./types"
+import type { NavItem, NavSections } from "./types"
import useTranslation from "@/hooks/useTranslation"
+import { buildNavigation } from "@/lib/nav/buildNavigation"
export const useNavigation = () => {
const { t } = useTranslation("common")
- const linkSections: NavSections = {
+ const linkSections: NavSections = buildNavigation(t)
+
+ const iconById: Record = {
+ "learn/overview": CompassIcon,
+ "learn/basics": UiChecksGridIcon,
+ "learn/advanced": SlidersHorizontalCircles,
+ "learn/quizzes": MortarboardIcon,
+ "use/get-started": PinAngleIcon,
+ "use/use-cases": LightbulbIcon,
+ "use/stake": SafeIcon,
+ "use/networks": LayersIcon,
+ "build/home": CodeSquareIcon,
+ "build/get-started": FlagIcon,
+ "build/docs": JournalCodeIcon,
+ "build/enterprise": BuildingsIcon,
+ "participate/community-hub": UsersFourLight,
+ "participate/events": MegaphoneIcon,
+ "participate/ethereum-org": EthereumIcon,
+ "research/whitepaper": BookIcon,
+ "research/roadmap": SignpostIcon,
+ "research/research": Flask,
+ }
+
+ const applyIconsToItems = (items: NavItem[]): NavItem[] =>
+ items.map((item) => {
+ const icon = item.id ? iconById[item.id] : undefined
+ if ("items" in item && item.items) {
+ return {
+ ...item,
+ ...(icon ? { icon } : {}),
+ items: applyIconsToItems(item.items),
+ }
+ }
+ return { ...item, ...(icon ? { icon } : {}) } as NavItem
+ })
+
+ const linkSectionsWithIcons: NavSections = {
learn: {
- label: t("learn"),
- ariaLabel: t("learn-menu"),
- items: [
- {
- label: t("nav-overview-label"),
- description: t("nav-overview-description"),
- icon: CompassIcon,
- href: "/learn/",
- },
- {
- label: t("nav-basics-label"),
- description: t("nav-basics-description"),
- icon: UiChecksGridIcon,
- items: [
- {
- label: t("what-is-ethereum"),
- description: t("nav-what-is-ethereum-description"),
- href: "/what-is-ethereum/",
- },
- {
- label: t("what-is-ether"),
- description: t("nav-what-is-ether-description"),
- href: "/eth/",
- },
- {
- label: t("ethereum-wallets"),
- description: t("nav-ethereum-wallets-description"),
- href: "/wallets/",
- },
- {
- label: t("nav-what-is-web3-label"),
- description: t("nav-what-is-web3-description"),
- href: "/web3/",
- },
- {
- label: t("smart-contracts"),
- description: t("nav-smart-contracts-description"),
- href: "/smart-contracts/",
- },
- ],
- },
- {
- label: t("nav-advanced-label"),
- description: t("nav-advanced-description"),
- icon: SlidersHorizontalCircles,
- items: [
- {
- label: t("nav-gas-fees-label"),
- description: t("nav-gas-fees-description"),
- href: "/gas/",
- },
- {
- label: t("bridges"),
- description: t("nav-bridges-description"),
- href: "/bridges/",
- },
- {
- label: t("zero-knowledge-proofs"),
- description: t("nav-zkp-description"),
- href: "/zero-knowledge-proofs/",
- },
- {
- label: t("run-a-node"),
- description: t("nav-run-a-node-description"),
- href: "/run-a-node/",
- },
- {
- label: t("ethereum-security"),
- description: t("nav-security-description"),
- href: "/security/",
- },
- ],
- },
- {
- label: t("nav-quizzes-label"),
- description: t("nav-quizzes-description"),
- icon: MortarboardIcon,
- href: "/quizzes/",
- },
- ],
+ ...linkSections.learn,
+ items: applyIconsToItems(linkSections.learn.items),
},
use: {
- label: t("use"),
- ariaLabel: t("use-menu"),
- items: [
- {
- label: t("get-started"),
- description: t("nav-get-started-description"),
- icon: PinAngleIcon,
- items: [
- {
- label: t("nav-start-with-crypto-title"),
- description: t("nav-start-with-crypto-description"),
- href: "/start/",
- },
- {
- label: t("nav-find-wallet-label"),
- description: t("nav-find-wallet-description"),
- href: "/wallets/find-wallet/",
- },
- {
- label: t("get-eth"),
- description: t("nav-get-eth-description"),
- href: "/get-eth/",
- },
- {
- label: t("application-explorer"),
- description: t("nav-apps-description"),
- href: "/apps/",
- },
- {
- label: t("nav-guides-label"),
- description: t("nav-guides-description"),
- items: [
- {
- label: t("nav-overview-label"),
- description: t("nav-guide-overview-description"),
- href: "/guides/",
- },
- {
- label: t("nav-guide-create-account-label"),
- description: t("nav-guide-create-account-description"),
- href: "/guides/how-to-create-an-ethereum-account/",
- },
- {
- label: t("nav-guide-use-wallet-label"),
- description: t("nav-guide-use-wallet-description"),
- href: "/guides/how-to-use-a-wallet/",
- },
- {
- label: t("nav-guide-revoke-access-label"),
- description: t("nav-guide-revoke-access-description"),
- href: "/guides/how-to-revoke-token-access/",
- },
- ],
- },
- ],
- },
- {
- label: t("nav-use-cases-label"),
- description: t("nav-use-cases-description"),
- icon: LightbulbIcon,
- items: [
- {
- label: t("stablecoins"),
- description: t("nav-stablecoins-description"),
- href: "/stablecoins/",
- },
- {
- label: t("nft-page"),
- description: t("nav-nft-description"),
- href: "/nft/",
- },
- {
- label: t("defi-page"),
- description: t("nav-defi-description"),
- href: "/defi/",
- },
- {
- label: t("payments-page"),
- description: t("nav-payments-description"),
- href: "/payments/",
- },
- {
- label: t("dao-page"),
- description: t("nav-dao-description"),
- href: "/dao/",
- },
- {
- label: t("nav-emerging-label"),
- description: t("nav-emerging-description"),
- items: [
- {
- label: t("decentralized-identity"),
- description: t("nav-did-description"),
- href: "/decentralized-identity/",
- },
- {
- label: t("decentralized-social-networks"),
- description: t("nav-desoc-description"),
- href: "/social-networks/",
- },
- {
- label: t("decentralized-science"),
- description: t("nav-desci-description"),
- href: "/desci/",
- },
- {
- label: t("regenerative-finance"),
- description: t("nav-refi-description"),
- href: "/refi/",
- },
- {
- label: t("ai-agents"),
- description: t("nav-ai-agents-description"),
- href: "/ai-agents/",
- },
- {
- label: t("prediction-markets"),
- description: t("nav-prediction-markets-description"),
- href: "/prediction-markets/",
- },
- {
- label: t("real-world-assets"),
- description: t("nav-rwa-description"),
- href: "/real-world-assets/",
- },
- ],
- },
- ],
- },
- {
- label: t("nav-stake-label"),
- description: t("nav-stake-description"),
- icon: SafeIcon,
- items: [
- {
- label: t("nav-staking-home-label"),
- description: t("nav-staking-home-description"),
- href: "/staking/",
- },
- {
- label: t("nav-staking-solo-label"),
- description: t("nav-staking-solo-description"),
- href: "/staking/solo/",
- },
- {
- label: t("nav-staking-saas-label"),
- description: t("nav-staking-saas-description"),
- href: "/staking/saas/",
- },
- {
- label: t("nav-staking-pool-label"),
- description: t("nav-staking-pool-description"),
- href: "/staking/pools/",
- },
- ],
- },
- {
- label: t("nav-ethereum-networks"),
- description: t("nav-ethereum-networks-description"),
- icon: LayersIcon,
- items: [
- {
- label: t("nav-networks-introduction-label"),
- description: t("nav-networks-introduction-description"),
- href: "/layer-2/",
- },
- {
- label: t("nav-networks-explore-networks-label"),
- description: t("nav-networks-explore-networks-description"),
- href: "/layer-2/networks/",
- },
- {
- label: t("nav-networks-learn-label"),
- description: t("nav-networks-learn-description"),
- href: "/layer-2/learn/",
- },
- ],
- },
- ],
+ ...linkSections.use,
+ items: applyIconsToItems(linkSections.use.items),
},
build: {
- label: t("build"),
- ariaLabel: t("build-menu"),
- items: [
- {
- label: t("nav-builders-home-label"),
- description: t("nav-builders-home-description"),
- icon: CodeSquareIcon,
- href: "/developers/",
- },
- {
- label: t("get-started"),
- description: t("nav-start-building-description"),
- icon: FlagIcon,
- items: [
- {
- label: t("tutorials"),
- description: t("nav-tutorials-description"),
- href: "/developers/tutorials/",
- },
- {
- label: t("learn-by-coding"),
- description: t("nav-learn-by-coding-description"),
- href: "/developers/learning-tools/",
- },
- {
- label: t("set-up-local-env"),
- description: t("nav-local-env-description"),
- href: "/developers/local-environment/",
- },
- {
- label: t("grants"),
- description: t("nav-grants-description"),
- href: "/community/grants/",
- },
- ],
- },
- {
- label: t("documentation"),
- description: t("nav-docs-description"),
- icon: JournalCodeIcon,
- items: [
- {
- label: t("nav-overview-label"),
- description: t("nav-docs-overview-description"),
- href: "/developers/docs/",
- },
- {
- label: t("nav-docs-foundation-label"),
- description: t("nav-docs-foundation-description"),
- href: "/developers/docs/intro-to-ethereum/",
- },
- {
- label: t("nav-docs-stack-label"),
- description: t("nav-docs-stack-description"),
- href: "/developers/docs/ethereum-stack/",
- },
- {
- label: t("nav-docs-design-label"),
- description: t("nav-docs-design-description"),
- href: "/developers/docs/design-and-ux/",
- },
- ],
- },
- {
- label: t("enterprise"),
- description: t("nav-mainnet-description"),
- icon: BuildingsIcon,
- href: "/enterprise/",
- },
- ],
+ ...linkSections.build,
+ items: applyIconsToItems(linkSections.build.items),
},
participate: {
- label: t("participate"),
- ariaLabel: t("participate-menu"),
- items: [
- {
- label: t("community-hub"),
- description: t("nav-participate-overview-description"),
- icon: UsersFourLight,
- href: "/community/",
- },
- {
- label: t("nav-events-label"),
- description: t("nav-events-description"),
- icon: MegaphoneIcon,
- items: [
- {
- label: t("ethereum-online"),
- description: t("nav-events-online-description"),
- href: "/community/online/",
- },
- {
- label: t("ethereum-events"),
- description: t("nav-events-irl-description"),
- href: "/community/events/",
- },
- ],
- },
- {
- label: t("site-title"),
- description: t("nav-ethereum-org-description"),
- icon: EthereumIcon,
- items: [
- {
- label: t("nav-contribute-label"),
- description: t("nav-contribute-description"),
- href: "/contributing/",
- },
- {
- label: t("translation-program"),
- description: t("nav-translation-program-description"),
- href: "/contributing/translation-program/",
- },
- {
- label: t("nav-collectibles-label"),
- description: t("nav-collectibles-description"),
- href: "/collectibles/",
- },
- {
- label: t("about-ethereum-org"),
- description: t("nav-about-description"),
- href: "/about/",
- },
- ],
- },
- ],
+ ...linkSections.participate,
+ items: applyIconsToItems(linkSections.participate.items),
},
research: {
- label: t("research"),
- ariaLabel: t("research-menu"),
- items: [
- {
- label: t("ethereum-whitepaper"),
- description: t("nav-whitepaper-description"),
- icon: BookIcon,
- href: "/whitepaper/",
- },
- {
- label: t("nav-roadmap-label"),
- description: t("nav-roadmap-description"),
- icon: SignpostIcon,
- items: [
- {
- label: t("nav-overview-label"),
- description: t("nav-roadmap-overview-description"),
- href: "/roadmap/",
- },
- {
- label: t("nav-roadmap-security-label"),
- description: t("nav-roadmap-security-description"),
- href: "/roadmap/security/",
- },
- {
- label: t("nav-roadmap-scaling-label"),
- description: t("nav-roadmap-scaling-description"),
- href: "/roadmap/scaling/",
- },
- {
- label: t("nav-roadmap-ux-label"),
- description: t("nav-roadmap-ux-description"),
- href: "/roadmap/user-experience/",
- },
- {
- label: t("nav-roadmap-future-label"),
- description: t("nav-roadmap-future-description"),
- href: "/roadmap/future-proofing/",
- },
- ],
- },
- {
- label: t("nav-research-label"),
- description: t("nav-research-description"),
- icon: Flask,
- items: [
- {
- label: t("nav-history-label"),
- description: t("nav-history-description"),
- href: "/history/",
- },
- {
- label: t("nav-open-research-label"),
- description: t("nav-open-research-description"),
- href: "/community/research/",
- },
- {
- label: t("nav-eip-label"),
- description: t("nav-eip-description"),
- href: "/eips/",
- },
- {
- label: t("nav-governance-label"),
- description: t("nav-governance-description"),
- href: "/governance/",
- },
- ],
- },
- ],
+ ...linkSections.research,
+ items: applyIconsToItems(linkSections.research.items),
},
}
- return { linkSections }
+ return { linkSections: linkSectionsWithIcons }
}
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 7262ef0b5c6..b661208dd6f 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -5,21 +5,26 @@ import dynamic from "next/dynamic"
import { useLocale } from "next-intl"
import { type DocSearchHit, useDocSearchKeyboardEvents } from "@docsearch/react"
import * as Portal from "@radix-ui/react-portal"
+import { Slot } from "@radix-ui/react-slot"
import { trackCustomEvent } from "@/lib/utils/matomo"
import { sanitizeHitTitle } from "@/lib/utils/sanitizeHitTitle"
import { sanitizeHitUrl } from "@/lib/utils/url"
+import SearchButton from "./SearchButton"
+import SearchInputButton from "./SearchInputButton"
+
import { useDisclosure } from "@/hooks/useDisclosure"
import { useTranslation } from "@/hooks/useTranslation"
const SearchModal = dynamic(() => import("./SearchModal"))
-type Props = {
- children: (props: ReturnType) => React.ReactNode
+interface SearchProps {
+ asChild?: boolean
+ children?: React.ReactElement
}
-const Search = ({ children }: Props) => {
+const Search = ({ asChild = false, children }: SearchProps) => {
const disclosure = useDisclosure()
const { isOpen, onOpen, onClose } = disclosure
@@ -48,75 +53,87 @@ const Search = ({ children }: Props) => {
const indexName =
process.env.NEXT_PUBLIC_ALGOLIA_BASE_SEARCH_INDEX_NAME || "ethereumorg"
+ const searchModalProps = {
+ apiKey,
+ appId,
+ indexName,
+ onClose,
+ searchParameters: {
+ facetFilters: [`lang:${locale}`],
+ },
+ transformItems: (items: DocSearchHit[]) =>
+ items.map((item: DocSearchHit) => {
+ const newItem: DocSearchHit = structuredClone(item)
+ newItem.url = sanitizeHitUrl(item.url)
+ const newTitle = sanitizeHitTitle(item.hierarchy.lvl0 || "")
+ newItem.hierarchy.lvl0 = newTitle
+ return newItem
+ }),
+ placeholder: t("search-ethereum-org"),
+ translations: {
+ searchBox: {
+ resetButtonTitle: t("clear"),
+ resetButtonAriaLabel: t("clear"),
+ cancelButtonText: t("close"),
+ cancelButtonAriaLabel: t("close"),
+ },
+ footer: {
+ selectText: t("docsearch-to-select"),
+ selectKeyAriaLabel: t("docsearch-to-select"),
+ navigateText: t("docsearch-to-navigate"),
+ navigateUpKeyAriaLabel: t("up"),
+ navigateDownKeyAriaLabel: t("down"),
+ closeText: t("docsearch-to-close"),
+ closeKeyAriaLabel: t("docsearch-to-close"),
+ searchByText: t("docsearch-search-by"),
+ },
+ errorScreen: {
+ titleText: t("docsearch-error-title"),
+ helpText: t("docsearch-error-help"),
+ },
+ startScreen: {
+ recentSearchesTitle: t("docsearch-start-recent-searches-title"),
+ noRecentSearchesText: t("docsearch-start-no-recent-searches"),
+ saveRecentSearchButtonTitle: t("docsearch-start-save-recent-search"),
+ removeRecentSearchButtonTitle: t(
+ "docsearch-start-remove-recent-search"
+ ),
+ favoriteSearchesTitle: t("docsearch-start-favorite-searches"),
+ removeFavoriteSearchButtonTitle: t(
+ "docsearch-start-remove-favorite-search"
+ ),
+ },
+ noResultsScreen: {
+ noResultsText: t("docsearch-no-results-text"),
+ suggestedQueryText: t("docsearch-no-results-suggested-query"),
+ reportMissingResultsText: t("docsearch-no-results-missing"),
+ reportMissingResultsLinkText: t("docsearch-no-results-missing-link"),
+ },
+ },
+ }
+
return (
<>
- {children({ ...disclosure, onOpen: handleOpen })}
-
- {isOpen && (
-
- items.map((item: DocSearchHit) => {
- const newItem: DocSearchHit = structuredClone(item)
- newItem.url = sanitizeHitUrl(item.url)
- const newTitle = sanitizeHitTitle(item.hierarchy.lvl0 || "")
- newItem.hierarchy.lvl0 = newTitle
- return newItem
- })
- }
- placeholder={t("search-ethereum-org")}
- translations={{
- searchBox: {
- resetButtonTitle: t("clear"),
- resetButtonAriaLabel: t("clear"),
- cancelButtonText: t("close"),
- cancelButtonAriaLabel: t("close"),
- },
- footer: {
- selectText: t("docsearch-to-select"),
- selectKeyAriaLabel: t("docsearch-to-select"),
- navigateText: t("docsearch-to-navigate"),
- navigateUpKeyAriaLabel: t("up"),
- navigateDownKeyAriaLabel: t("down"),
- closeText: t("docsearch-to-close"),
- closeKeyAriaLabel: t("docsearch-to-close"),
- searchByText: t("docsearch-search-by"),
- },
- errorScreen: {
- titleText: t("docsearch-error-title"),
- helpText: t("docsearch-error-help"),
- },
- startScreen: {
- recentSearchesTitle: t("docsearch-start-recent-searches-title"),
- noRecentSearchesText: t("docsearch-start-no-recent-searches"),
- saveRecentSearchButtonTitle: t(
- "docsearch-start-save-recent-search"
- ),
- removeRecentSearchButtonTitle: t(
- "docsearch-start-remove-recent-search"
- ),
- favoriteSearchesTitle: t("docsearch-start-favorite-searches"),
- removeFavoriteSearchButtonTitle: t(
- "docsearch-start-remove-favorite-search"
- ),
- },
- noResultsScreen: {
- noResultsText: t("docsearch-no-results-text"),
- suggestedQueryText: t("docsearch-no-results-suggested-query"),
- reportMissingResultsText: t("docsearch-no-results-missing"),
- reportMissingResultsLinkText: t(
- "docsearch-no-results-missing-link"
- ),
- },
- }}
+ {asChild ? (
+
+ {children}
+
+ ) : (
+ <>
+
- )}
+
+ >
+ )}
+
+ {isOpen && }
>
)
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
new file mode 100644
index 00000000000..ee7163c1414
--- /dev/null
+++ b/src/components/ui/collapsible.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { PropsWithChildren, useCallback } from "react"
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+type CollapsibleTrackedProps = PropsWithChildren<{
+ className?: string
+ eventCategory: string
+ eventAction: string
+ openEventName: string
+ closeEventName: string
+}>
+
+const CollapsibleTracked = ({
+ className,
+ children,
+ eventCategory,
+ eventAction,
+ openEventName,
+ closeEventName,
+}: CollapsibleTrackedProps) => {
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ trackCustomEvent({
+ eventCategory,
+ eventAction,
+ eventName: open ? openEventName : closeEventName,
+ })
+ },
+ [eventCategory, eventAction, openEventName, closeEventName]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTracked,
+ CollapsibleTrigger,
+}
diff --git a/src/components/ui/sheet-close-on-navigate.tsx b/src/components/ui/sheet-close-on-navigate.tsx
new file mode 100644
index 00000000000..060c78abdd3
--- /dev/null
+++ b/src/components/ui/sheet-close-on-navigate.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { usePathname } from "next/navigation"
+
+import { Sheet as BaseSheet } from "./sheet"
+
+type BaseSheetProps = React.ComponentProps
+
+const SheetCloseOnNavigate: React.FC = ({
+ children,
+ ...props
+}) => {
+ const pathname = usePathname()
+ const [open, setOpen] = useState(false)
+
+ React.useEffect(() => {
+ if (open) setOpen(false)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { SheetCloseOnNavigate }
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index 5e643c0034e..cbb96074c71 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -22,6 +22,12 @@ const SheetClose = React.forwardRef<
))
SheetClose.displayName = SheetPrimitive.Close.displayName
+const SheetDismiss = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => )
+SheetDismiss.displayName = SheetPrimitive.Close.displayName
+
const SheetOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
@@ -58,14 +64,16 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
- VariantProps {}
+ VariantProps {
+ hideOverlay?: boolean
+}
const SheetContent = React.forwardRef<
React.ElementRef,
SheetContentProps
->(({ side = "right", className, ...props }, ref) => (
+>(({ side = "right", hideOverlay = false, className, ...props }, ref) => (
-
+ {!hideOverlay && }
string
+
+export const buildNavigation = (t: TranslateFn): NavSections => {
+ return {
+ learn: {
+ label: t("learn"),
+ ariaLabel: t("learn-menu"),
+ items: [
+ {
+ id: "learn/overview",
+ label: t("nav-overview-label"),
+ description: t("nav-overview-description"),
+ href: "/learn/",
+ },
+ {
+ id: "learn/basics",
+ label: t("nav-basics-label"),
+ description: t("nav-basics-description"),
+ items: [
+ {
+ label: t("what-is-ethereum"),
+ description: t("nav-what-is-ethereum-description"),
+ href: "/what-is-ethereum/",
+ },
+ {
+ label: t("what-is-ether"),
+ description: t("nav-what-is-ether-description"),
+ href: "/eth/",
+ },
+ {
+ label: t("ethereum-wallets"),
+ description: t("nav-ethereum-wallets-description"),
+ href: "/wallets/",
+ },
+ {
+ label: t("nav-what-is-web3-label"),
+ description: t("nav-what-is-web3-description"),
+ href: "/web3/",
+ },
+ {
+ label: t("smart-contracts"),
+ description: t("nav-smart-contracts-description"),
+ href: "/smart-contracts/",
+ },
+ ],
+ },
+ {
+ id: "learn/advanced",
+ label: t("nav-advanced-label"),
+ description: t("nav-advanced-description"),
+ items: [
+ {
+ label: t("nav-gas-fees-label"),
+ description: t("nav-gas-fees-description"),
+ href: "/gas/",
+ },
+ {
+ label: t("bridges"),
+ description: t("nav-bridges-description"),
+ href: "/bridges/",
+ },
+ {
+ label: t("zero-knowledge-proofs"),
+ description: t("nav-zkp-description"),
+ href: "/zero-knowledge-proofs/",
+ },
+ {
+ label: t("run-a-node"),
+ description: t("nav-run-a-node-description"),
+ href: "/run-a-node/",
+ },
+ {
+ label: t("ethereum-security"),
+ description: t("nav-security-description"),
+ href: "/security/",
+ },
+ ],
+ },
+ {
+ id: "learn/quizzes",
+ label: t("nav-quizzes-label"),
+ description: t("nav-quizzes-description"),
+ href: "/quizzes/",
+ },
+ ],
+ },
+ use: {
+ label: t("use"),
+ ariaLabel: t("use-menu"),
+ items: [
+ {
+ id: "use/get-started",
+ label: t("get-started"),
+ description: t("nav-get-started-description"),
+ items: [
+ {
+ label: t("nav-start-with-crypto-title"),
+ description: t("nav-start-with-crypto-description"),
+ href: "/start/",
+ },
+ {
+ label: t("nav-find-wallet-label"),
+ description: t("nav-find-wallet-description"),
+ href: "/wallets/find-wallet/",
+ },
+ {
+ label: t("get-eth"),
+ description: t("nav-get-eth-description"),
+ href: "/get-eth/",
+ },
+ {
+ label: t("application-explorer"),
+ description: t("nav-apps-description"),
+ href: "/apps/",
+ },
+ {
+ label: t("nav-guides-label"),
+ description: t("nav-guides-description"),
+ items: [
+ {
+ label: t("nav-overview-label"),
+ description: t("nav-guide-overview-description"),
+ href: "/guides/",
+ },
+ {
+ label: t("nav-guide-create-account-label"),
+ description: t("nav-guide-create-account-description"),
+ href: "/guides/how-to-create-an-ethereum-account/",
+ },
+ {
+ label: t("nav-guide-use-wallet-label"),
+ description: t("nav-guide-use-wallet-description"),
+ href: "/guides/how-to-use-a-wallet/",
+ },
+ {
+ label: t("nav-guide-revoke-access-label"),
+ description: t("nav-guide-revoke-access-description"),
+ href: "/guides/how-to-revoke-token-access/",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "use/use-cases",
+ label: t("nav-use-cases-label"),
+ description: t("nav-use-cases-description"),
+ items: [
+ {
+ label: t("stablecoins"),
+ description: t("nav-stablecoins-description"),
+ href: "/stablecoins/",
+ },
+ {
+ label: t("nft-page"),
+ description: t("nav-nft-description"),
+ href: "/nft/",
+ },
+ {
+ label: t("defi-page"),
+ description: t("nav-defi-description"),
+ href: "/defi/",
+ },
+ {
+ label: t("payments-page"),
+ description: t("nav-payments-description"),
+ href: "/payments/",
+ },
+ {
+ label: t("dao-page"),
+ description: t("nav-dao-description"),
+ href: "/dao/",
+ },
+ {
+ label: t("nav-emerging-label"),
+ description: t("nav-emerging-description"),
+ items: [
+ {
+ label: t("decentralized-identity"),
+ description: t("nav-did-description"),
+ href: "/decentralized-identity/",
+ },
+ {
+ label: t("decentralized-social-networks"),
+ description: t("nav-desoc-description"),
+ href: "/social-networks/",
+ },
+ {
+ label: t("decentralized-science"),
+ description: t("nav-desci-description"),
+ href: "/desci/",
+ },
+ {
+ label: t("regenerative-finance"),
+ description: t("nav-refi-description"),
+ href: "/refi/",
+ },
+ {
+ label: t("ai-agents"),
+ description: t("nav-ai-agents-description"),
+ href: "/ai-agents/",
+ },
+ {
+ label: t("prediction-markets"),
+ description: t("nav-prediction-markets-description"),
+ href: "/prediction-markets/",
+ },
+ {
+ label: t("real-world-assets"),
+ description: t("nav-rwa-description"),
+ href: "/real-world-assets/",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "use/stake",
+ label: t("nav-stake-label"),
+ description: t("nav-stake-description"),
+ items: [
+ {
+ label: t("nav-staking-home-label"),
+ description: t("nav-staking-home-description"),
+ href: "/staking/",
+ },
+ {
+ label: t("nav-staking-solo-label"),
+ description: t("nav-staking-solo-description"),
+ href: "/staking/solo/",
+ },
+ {
+ label: t("nav-staking-saas-label"),
+ description: t("nav-staking-saas-description"),
+ href: "/staking/saas/",
+ },
+ {
+ label: t("nav-staking-pool-label"),
+ description: t("nav-staking-pool-description"),
+ href: "/staking/pools/",
+ },
+ ],
+ },
+ {
+ id: "use/networks",
+ label: t("nav-ethereum-networks"),
+ description: t("nav-ethereum-networks-description"),
+ items: [
+ {
+ label: t("nav-networks-introduction-label"),
+ description: t("nav-networks-introduction-description"),
+ href: "/layer-2/",
+ },
+ {
+ label: t("nav-networks-explore-networks-label"),
+ description: t("nav-networks-explore-networks-description"),
+ href: "/layer-2/networks/",
+ },
+ {
+ label: t("nav-networks-learn-label"),
+ description: t("nav-networks-learn-description"),
+ href: "/layer-2/learn/",
+ },
+ ],
+ },
+ ],
+ },
+ build: {
+ label: t("build"),
+ ariaLabel: t("build-menu"),
+ items: [
+ {
+ id: "build/home",
+ label: t("nav-builders-home-label"),
+ description: t("nav-builders-home-description"),
+ href: "/developers/",
+ },
+ {
+ id: "build/get-started",
+ label: t("get-started"),
+ description: t("nav-start-building-description"),
+ items: [
+ {
+ label: t("tutorials"),
+ description: t("nav-tutorials-description"),
+ href: "/developers/tutorials/",
+ },
+ {
+ label: t("learn-by-coding"),
+ description: t("nav-learn-by-coding-description"),
+ href: "/developers/learning-tools/",
+ },
+ {
+ label: t("set-up-local-env"),
+ description: t("nav-local-env-description"),
+ href: "/developers/local-environment/",
+ },
+ {
+ label: t("grants"),
+ description: t("nav-grants-description"),
+ href: "/community/grants/",
+ },
+ ],
+ },
+ {
+ id: "build/docs",
+ label: t("documentation"),
+ description: t("nav-docs-description"),
+ items: [
+ {
+ label: t("nav-overview-label"),
+ description: t("nav-docs-overview-description"),
+ href: "/developers/docs/",
+ },
+ {
+ label: t("nav-docs-foundation-label"),
+ description: t("nav-docs-foundation-description"),
+ href: "/developers/docs/intro-to-ethereum/",
+ },
+ {
+ label: t("nav-docs-stack-label"),
+ description: t("nav-docs-stack-description"),
+ href: "/developers/docs/ethereum-stack/",
+ },
+ {
+ label: t("nav-docs-design-label"),
+ description: t("nav-docs-design-description"),
+ href: "/developers/docs/design-and-ux/",
+ },
+ ],
+ },
+ {
+ id: "build/enterprise",
+ label: t("enterprise"),
+ description: t("nav-mainnet-description"),
+ href: "/enterprise/",
+ },
+ ],
+ },
+ participate: {
+ label: t("participate"),
+ ariaLabel: t("participate-menu"),
+ items: [
+ {
+ id: "participate/community-hub",
+ label: t("community-hub"),
+ description: t("nav-participate-overview-description"),
+ href: "/community/",
+ },
+ {
+ id: "participate/events",
+ label: t("nav-events-label"),
+ description: t("nav-events-description"),
+ items: [
+ {
+ label: t("ethereum-online"),
+ description: t("nav-events-online-description"),
+ href: "/community/online/",
+ },
+ {
+ label: t("ethereum-events"),
+ description: t("nav-events-irl-description"),
+ href: "/community/events/",
+ },
+ ],
+ },
+ {
+ id: "participate/ethereum-org",
+ label: t("site-title"),
+ description: t("nav-ethereum-org-description"),
+ items: [
+ {
+ label: t("nav-contribute-label"),
+ description: t("nav-contribute-description"),
+ href: "/contributing/",
+ },
+ {
+ label: t("translation-program"),
+ description: t("nav-translation-program-description"),
+ href: "/contributing/translation-program/",
+ },
+ {
+ label: t("nav-collectibles-label"),
+ description: t("nav-collectibles-description"),
+ href: "/collectibles/",
+ },
+ {
+ label: t("about-ethereum-org"),
+ description: t("nav-about-description"),
+ href: "/about/",
+ },
+ ],
+ },
+ ],
+ },
+ research: {
+ label: t("research"),
+ ariaLabel: t("research-menu"),
+ items: [
+ {
+ id: "research/whitepaper",
+ label: t("ethereum-whitepaper"),
+ description: t("nav-whitepaper-description"),
+ href: "/whitepaper/",
+ },
+ {
+ id: "research/roadmap",
+ label: t("nav-roadmap-label"),
+ description: t("nav-roadmap-description"),
+ items: [
+ {
+ label: t("nav-overview-label"),
+ description: t("nav-roadmap-overview-description"),
+ href: "/roadmap/",
+ },
+ {
+ label: t("nav-roadmap-security-label"),
+ description: t("nav-roadmap-security-description"),
+ href: "/roadmap/security/",
+ },
+ {
+ label: t("nav-roadmap-scaling-label"),
+ description: t("nav-roadmap-scaling-description"),
+ href: "/roadmap/scaling/",
+ },
+ {
+ label: t("nav-roadmap-ux-label"),
+ description: t("nav-roadmap-ux-description"),
+ href: "/roadmap/user-experience/",
+ },
+ {
+ label: t("nav-roadmap-future-label"),
+ description: t("nav-roadmap-future-description"),
+ href: "/roadmap/future-proofing/",
+ },
+ ],
+ },
+ {
+ id: "research/research",
+ label: t("nav-research-label"),
+ description: t("nav-research-description"),
+ items: [
+ {
+ label: t("nav-history-label"),
+ description: t("nav-history-description"),
+ href: "/history/",
+ },
+ {
+ label: t("nav-open-research-label"),
+ description: t("nav-open-research-description"),
+ href: "/community/research/",
+ },
+ {
+ label: t("nav-eip-label"),
+ description: t("nav-eip-description"),
+ href: "/eips/",
+ },
+ {
+ label: t("nav-governance-label"),
+ description: t("nav-governance-description"),
+ href: "/governance/",
+ },
+ ],
+ },
+ ],
+ },
+ }
+}
diff --git a/src/lib/nav/links.ts b/src/lib/nav/links.ts
new file mode 100644
index 00000000000..b64468d8fc2
--- /dev/null
+++ b/src/lib/nav/links.ts
@@ -0,0 +1,43 @@
+import { getLocale, getTranslations } from "next-intl/server"
+
+import type { Lang, LocaleDisplayInfo } from "@/lib/types"
+
+import type { NavSections } from "@/components/Nav/types"
+
+import { filterRealLocales } from "@/lib/utils/translations"
+
+import { LOCALES_CODES } from "@/lib/constants"
+
+import { buildNavigation } from "@/lib/nav/buildNavigation"
+import { localeToDisplayInfo } from "@/lib/nav/localeToDisplayInfo"
+
+// Pre-filtered locales for server use
+const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES)
+
+/**
+ * Server-side function to generate language display information
+ * Processes all locale data on the server without client-side hooks
+ */
+export const getLanguagesDisplayInfo = async (): Promise<
+ LocaleDisplayInfo[]
+> => {
+ const locale = await getLocale()
+ const t = await getTranslations({ locale, namespace: "common" })
+
+ // Early return if no locales
+ if (!FILTERED_LOCALES?.length) return []
+
+ return (FILTERED_LOCALES as Lang[]).map((localeOption) => {
+ return localeToDisplayInfo(localeOption, locale as Lang, t)
+ })
+}
+
+export const getNavigation = async () => {
+ const t = await getTranslations({
+ namespace: "common",
+ })
+
+ const linkSections: NavSections = buildNavigation(t)
+
+ return linkSections
+}
diff --git a/src/components/LanguagePicker/localeToDisplayInfo.ts b/src/lib/nav/localeToDisplayInfo.ts
similarity index 83%
rename from src/components/LanguagePicker/localeToDisplayInfo.ts
rename to src/lib/nav/localeToDisplayInfo.ts
index 916bd868746..50f97a03efc 100644
--- a/src/components/LanguagePicker/localeToDisplayInfo.ts
+++ b/src/lib/nav/localeToDisplayInfo.ts
@@ -13,6 +13,20 @@ import { DEFAULT_LOCALE } from "@/lib/constants"
const progressData = progressDataJson satisfies ProjectProgressData[]
+const getProgressInfo = (
+ locale: Lang,
+ approvalProgress: number,
+ wordsApproved: number
+) => {
+ const percentage = new Intl.NumberFormat(locale, {
+ style: "percent",
+ }).format(approvalProgress / 100)
+ const progress =
+ approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage
+ const words = new Intl.NumberFormat(locale).format(wordsApproved)
+ return { progress, words }
+}
+
export const localeToDisplayInfo = (
localeOption: Lang,
sourceLocale: Lang,
@@ -87,9 +101,17 @@ export const localeToDisplayInfo = (
? totalWords || 0
: dataItem?.words.approved || 0
+ const { progress, words } = getProgressInfo(
+ localeOption,
+ approvalProgress,
+ wordsApproved
+ )
+
return {
...returnData,
approvalProgress,
wordsApproved,
+ progress,
+ words,
} as LocaleDisplayInfo
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 8648cfd3020..219d10c765d 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -294,6 +294,8 @@ export type LocaleDisplayInfo = {
englishName: string
approvalProgress: number
wordsApproved: number
+ progress: string
+ words: string
isBrowserDefault?: boolean
}
diff --git a/tests/e2e/pages/BasePage.ts b/tests/e2e/pages/BasePage.ts
index 82e953b0843..41b5f392a80 100644
--- a/tests/e2e/pages/BasePage.ts
+++ b/tests/e2e/pages/BasePage.ts
@@ -118,7 +118,7 @@ export class BasePage {
*/
async openLanguagePickerMobile(): Promise {
await this.mobileMenuButton.click()
- await this.mobileSidebar.getByRole("button", { name: /languages/i }).click()
+ await this.mobileSidebar.getByTestId("mobile-menu-language-picker").click()
}
/**