feat(web): integrate localized application catalogs
- compose app, UI, and independently imported block catalogs from JSON configuration\n- persist the selected locale on the server and activate it without hydration flicker\n- add a flag-based language switcher and localized navigation descriptors\n- mount the redesigned i18n devtool only in development\n- regenerate English and Simplified Chinese catalogs and add switcher coverage
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
const LazyI18nDevtool = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import("@workspace/i18n/devtool").then((module) => ({
|
||||
default: module.I18nDevtool,
|
||||
}))
|
||||
)
|
||||
: null
|
||||
|
||||
export function AppI18nDevtool() {
|
||||
if (!LazyI18nDevtool) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyI18nDevtool dark={"ui\\:dark"} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +1,105 @@
|
||||
import { lazy, Suspense, type PropsWithChildren } from "react"
|
||||
import { I18nProvider, type LocaleDefinition } from "@workspace/i18n"
|
||||
import {
|
||||
createContext,
|
||||
startTransition,
|
||||
use,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from "react"
|
||||
import {
|
||||
I18nProvider,
|
||||
useLocaleDefinition,
|
||||
useTranslate,
|
||||
} from "@workspace/i18n"
|
||||
import { loadMessageCatalog } from "@workspace/i18n/catalogs"
|
||||
import {
|
||||
I18nProvider as UiI18nProvider,
|
||||
type CalendarLocale,
|
||||
} from "@workspace/ui/components/i18n"
|
||||
import { calendarLocale as englishCalendarLocale } from "@workspace/ui/locales/en"
|
||||
import { calendarLocale as simplifiedChineseCalendarLocale } from "@workspace/ui/locales/zh-Hans"
|
||||
|
||||
import { messages as englishMessages } from "@/locales/en/messages"
|
||||
import { messages as simplifiedChineseMessages } from "@/locales/zh-Hans/messages"
|
||||
import { appLocales, DEFAULT_APP_LOCALE, type AppLocale } from "./app-locales"
|
||||
import { AppI18nDevtool } from "./app-i18n-devtool"
|
||||
import { setAppLocale as persistAppLocale } from "@/server/app-locale.functions"
|
||||
|
||||
const I18nDevtool = import.meta.env.DEV
|
||||
? lazy(() =>
|
||||
import("@workspace/i18n/devtool").then((module) => ({
|
||||
default: module.I18nDevtool,
|
||||
}))
|
||||
)
|
||||
: null
|
||||
|
||||
export const appLocales = [
|
||||
{
|
||||
label: "English",
|
||||
locale: "en",
|
||||
},
|
||||
{
|
||||
label: "简体中文",
|
||||
locale: "zh-Hans",
|
||||
},
|
||||
] as const satisfies readonly LocaleDefinition[]
|
||||
|
||||
export type AppLocale = (typeof appLocales)[number]["locale"]
|
||||
|
||||
const appCatalogs = {
|
||||
en: englishMessages,
|
||||
"zh-Hans": simplifiedChineseMessages,
|
||||
const appCalendarLocales: Partial<Record<AppLocale, CalendarLocale>> = {
|
||||
en: englishCalendarLocale,
|
||||
"zh-Hans": simplifiedChineseCalendarLocale,
|
||||
}
|
||||
|
||||
export interface AppI18nProviderProps extends PropsWithChildren {
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
interface AppLocaleContextValue {
|
||||
locale: AppLocale
|
||||
setLocale: (locale: AppLocale) => void
|
||||
}
|
||||
|
||||
const AppLocaleContext = createContext<AppLocaleContextValue | null>(null)
|
||||
|
||||
export function AppI18nProvider({
|
||||
children,
|
||||
locale = "zh-Hans",
|
||||
locale: initialLocale = DEFAULT_APP_LOCALE,
|
||||
}: AppI18nProviderProps) {
|
||||
const [locale, setLocaleState] = useState(initialLocale)
|
||||
const messages = use(loadMessageCatalog(locale))
|
||||
const catalogs = useMemo(() => ({ [locale]: messages }), [locale, messages])
|
||||
const setLocale = useCallback((nextLocale: AppLocale) => {
|
||||
startTransition(() => {
|
||||
setLocaleState(nextLocale)
|
||||
})
|
||||
void persistAppLocale({ data: nextLocale })
|
||||
}, [])
|
||||
const localeContext = useMemo(
|
||||
() => ({ locale, setLocale }),
|
||||
[locale, setLocale]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const definition = appLocales.find(
|
||||
(candidate) => candidate.locale === locale
|
||||
)
|
||||
|
||||
document.documentElement.lang = definition?.languageTag ?? locale
|
||||
document.documentElement.dir = definition?.direction ?? "ltr"
|
||||
}, [locale])
|
||||
|
||||
return (
|
||||
<I18nProvider catalogs={appCatalogs} locale={locale} locales={appLocales}>
|
||||
{children}
|
||||
{I18nDevtool && (
|
||||
<Suspense fallback={null}>
|
||||
<I18nDevtool dark={"ui\\:dark"} />
|
||||
</Suspense>
|
||||
)}
|
||||
</I18nProvider>
|
||||
<AppLocaleContext.Provider value={localeContext}>
|
||||
<I18nProvider catalogs={catalogs} locale={locale} locales={appLocales}>
|
||||
<AppUiI18nProvider>{children}</AppUiI18nProvider>
|
||||
<AppI18nDevtool />
|
||||
</I18nProvider>
|
||||
</AppLocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
const context = useContext(AppLocaleContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAppLocale must be used within AppI18nProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function AppUiI18nProvider({ children }: PropsWithChildren) {
|
||||
const locale = useLocaleDefinition()
|
||||
const translate = useTranslate()
|
||||
|
||||
return (
|
||||
<UiI18nProvider
|
||||
calendarLocale={appCalendarLocales[locale.locale as AppLocale]}
|
||||
locale={locale.languageTag ?? locale.locale}
|
||||
t={translate}
|
||||
>
|
||||
{children}
|
||||
</UiI18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { AppLanguageSwitcher } from "./app-language-switcher"
|
||||
|
||||
vi.mock("./app-i18n-provider", () => ({
|
||||
useAppLocale: () => ({
|
||||
locale: "en",
|
||||
setLocale: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("country-flag-icons/react/1x1", () => ({
|
||||
CN: () => <svg />,
|
||||
US: () => <svg />,
|
||||
}))
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe("AppLanguageSwitcher", () => {
|
||||
it("opens its grouped locale choices", async () => {
|
||||
render(<AppLanguageSwitcher />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Change language, currently English",
|
||||
})
|
||||
)
|
||||
|
||||
expect(await screen.findByText("简体中文")).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { CN, US } from "country-flag-icons/react/1x1"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { HeaderActionButton } from "@workspace/blocks/layout"
|
||||
|
||||
import { appLocales, isAppLocale, type AppLocaleCountry } from "./app-locales"
|
||||
import { useAppLocale } from "./app-i18n-provider"
|
||||
|
||||
const localeFlags = {
|
||||
CN,
|
||||
US,
|
||||
} satisfies Record<AppLocaleCountry, typeof CN>
|
||||
|
||||
function LocaleFlag({
|
||||
country,
|
||||
className,
|
||||
}: {
|
||||
className?: string
|
||||
country: AppLocaleCountry
|
||||
}) {
|
||||
const Flag = localeFlags[country]
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 overflow-hidden rounded-full ring-1 ring-foreground/15",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Flag className="size-full" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppLanguageSwitcher() {
|
||||
const { locale, setLocale } = useAppLocale()
|
||||
const activeLocale =
|
||||
appLocales.find((definition) => definition.locale === locale) ??
|
||||
appLocales[0]
|
||||
const menuLabel = locale === "zh-Hans" ? "语言" : "Language"
|
||||
const triggerLabel =
|
||||
locale === "zh-Hans"
|
||||
? `切换语言,当前为${activeLocale.label}`
|
||||
: `Change language, currently ${activeLocale.label}`
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<HeaderActionButton
|
||||
aria-label={triggerLabel}
|
||||
className="rounded-full"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LocaleFlag country={activeLocale.country} className="size-6" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44"
|
||||
showArrow={{ cornerRadius: "lg", tailSize: "sm" }}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={locale}
|
||||
onValueChange={(value) => {
|
||||
if (isAppLocale(value)) {
|
||||
setLocale(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>
|
||||
{appLocales.map((definition) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={definition.locale}
|
||||
className="py-2"
|
||||
value={definition.locale}
|
||||
>
|
||||
<LocaleFlag country={definition.country} className="size-5" />
|
||||
<span lang={definition.languageTag}>{definition.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { LocaleDefinition } from "@workspace/i18n"
|
||||
|
||||
export type AppLocale = "en" | "zh-Hans"
|
||||
export type AppLocaleCountry = "CN" | "US"
|
||||
|
||||
export interface AppLocaleDefinition extends LocaleDefinition {
|
||||
country: AppLocaleCountry
|
||||
locale: AppLocale
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_LOCALE: AppLocale = "zh-Hans"
|
||||
|
||||
export const appLocales = [
|
||||
{
|
||||
country: "US",
|
||||
direction: "ltr",
|
||||
languageTag: "en-US",
|
||||
label: "English",
|
||||
locale: "en",
|
||||
},
|
||||
{
|
||||
country: "CN",
|
||||
direction: "ltr",
|
||||
languageTag: "zh-CN",
|
||||
label: "简体中文",
|
||||
locale: "zh-Hans",
|
||||
},
|
||||
] as const satisfies readonly AppLocaleDefinition[]
|
||||
|
||||
export function isAppLocale(value: unknown): value is AppLocale {
|
||||
return appLocales.some((definition) => definition.locale === value)
|
||||
}
|
||||
Reference in New Issue
Block a user