diff --git a/packages/i18n/src/create-i18n/index.ts b/packages/i18n/src/create-i18n/index.ts index 89a0b3bda4c..a624e89b30d 100644 --- a/packages/i18n/src/create-i18n/index.ts +++ b/packages/i18n/src/create-i18n/index.ts @@ -160,7 +160,13 @@ export function createI18n($locale: ReadableStore, options: CreateI18nOp ): Messages { const out: Record = {}; for (const key in base) { - out[key] = buildEntry(locale, base[key], overridesForNamespace?.[key]); + const baseVal = base[key]; + const override = overridesForNamespace?.[key]; + if (typeof baseVal === 'object' && baseVal !== null && !asMarker(baseVal)) { + out[key] = buildMessages(locale, baseVal as Record, override as Overrides | undefined); + } else { + out[key] = buildEntry(locale, baseVal, override); + } } return out as Messages; } diff --git a/packages/swingset/src/components/StoryEmbed.tsx b/packages/swingset/src/components/StoryEmbed.tsx index 1457332727c..d3e91240d0b 100644 --- a/packages/swingset/src/components/StoryEmbed.tsx +++ b/packages/swingset/src/components/StoryEmbed.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { MosaicProvider } from '@clerk/ui/mosaic/providers/mosaic-provider'; import { Layers2Icon } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx index ac202903489..841e3990fc6 100644 --- a/packages/swingset/src/components/StoryPreview.tsx +++ b/packages/swingset/src/components/StoryPreview.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { MosaicProvider } from '@clerk/ui/mosaic/providers/mosaic-provider'; import { RotateCcwIcon, SlidersHorizontalIcon } from 'lucide-react'; import type React from 'react'; import { useEffect, useState } from 'react'; @@ -61,8 +61,8 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
{mounted && ( diff --git a/packages/ui/package.json b/packages/ui/package.json index b885f3a8ff2..dd48cf45413 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -94,6 +94,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@clerk/i18n": "workspace:^", "@clerk/localizations": "workspace:^", "@clerk/shared": "workspace:^", "@emotion/cache": "11.11.0", diff --git a/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx b/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx index 1e8958c7718..f87fcc147fe 100644 --- a/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx +++ b/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import type { MosaicAppearance } from '../appearance'; import { parseMosaicAppearance, useMosaicAppearance } from '../appearance'; -import { MosaicProvider, useMosaicTheme } from '../MosaicProvider'; +import { MosaicProvider, useMosaicTheme } from '../providers/mosaic-provider'; const appearance: MosaicAppearance = { elements: { @@ -42,7 +42,12 @@ describe('parseMosaicAppearance', () => { describe('MosaicProvider appearance context', () => { it('exposes [global, scoped] layers via useMosaicAppearance', () => { const { result } = renderHook(() => useMosaicAppearance(), { - wrapper: ({ children }) => React.createElement(MosaicProvider, { appearance, scope: 'signIn' }, children), + wrapper: ({ children }) => + React.createElement( + MosaicProvider, + { appearance: { elements: appearance.elements, scope: 'signIn' } }, + children, + ), }); expect(result.current).toEqual([{ button: { color: 'green' } }, { button: { color: 'red' } }]); }); diff --git a/packages/ui/src/mosaic/__tests__/i18n-server.test.ts b/packages/ui/src/mosaic/__tests__/i18n-server.test.ts new file mode 100644 index 00000000000..e2c11aa2e1a --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/i18n-server.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { getLocalization } from '../i18n/server'; + +describe('getLocalization', () => { + it('returns the default locale with no messages when acceptLanguage is null', async () => { + const result = await getLocalization(null); + expect(result).toEqual({ locale: 'en', initialMessages: {} }); + }); + + it('matches the default locale via base tag (en-US → en)', async () => { + const result = await getLocalization('en-US'); + expect(result).toEqual({ locale: 'en', initialMessages: {} }); + }); + + it('picks the highest-priority match (en-US,fr → en, not fr)', async () => { + const result = await getLocalization('en-US,fr;q=0.9'); + expect(result.locale).toBe('en'); + }); + + it('loads french messages when locale is fr', async () => { + const result = await getLocalization('fr'); + expect(result.locale).toBe('fr'); + expect(result.initialMessages).toHaveProperty('fr'); + expect(result.initialMessages.fr).toBeTruthy(); + }); + + it('matches a supported locale via base tag (fr-FR → fr)', async () => { + const result = await getLocalization('fr-FR'); + expect(result.locale).toBe('fr'); + }); + + it('falls back to the default locale for an unsupported language', async () => { + const result = await getLocalization('de'); + expect(result).toEqual({ locale: 'en', initialMessages: {} }); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/localization-provider.test.tsx b/packages/ui/src/mosaic/__tests__/localization-provider.test.tsx new file mode 100644 index 00000000000..8e7507ca1bf --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/localization-provider.test.tsx @@ -0,0 +1,31 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import frMessages from '../locales/fr.json'; +import { MosaicProvider } from '../providers/mosaic-provider'; +import { useMessages } from '../providers/localization-provider'; + +const base = { title: 'Organization Profile', tab: { general: 'General', members: 'Members' } }; + +describe('LocalizationProvider overrides', () => { + it('override wins over French translation when both locale and overrides are set', () => { + const { result } = renderHook(() => useMessages('organizationProfile', base), { + wrapper: ({ children }) => + React.createElement( + MosaicProvider, + { + localization: { + locale: 'fr', + initialMessages: { fr: frMessages }, + overrides: { 'organizationProfile.title': 'test' }, + }, + }, + children, + ), + }); + expect(result.current.title).toBe('test'); + // Non-overridden key still resolves from the French bundle + expect((result.current.tab as Record).general).toBe('Général'); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts b/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts index f50b76e7046..24b8a5084f5 100644 --- a/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts +++ b/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts @@ -3,13 +3,13 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import type { MosaicAppearance } from '../appearance'; -import { MosaicProvider } from '../MosaicProvider'; +import { MosaicProvider } from '../providers/mosaic-provider'; import { defineSlotRecipe, useRecipe } from '../slot-recipe'; import { slot, useSlot } from '../useSlot'; function wrapper(appearance?: MosaicAppearance, scope?: string) { return function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(MosaicProvider, { appearance, scope }, children); + return React.createElement(MosaicProvider, { appearance: { ...appearance, scope } }, children); }; } diff --git a/packages/ui/src/mosaic/aio/organization-profile.messages.ts b/packages/ui/src/mosaic/aio/organization-profile.messages.ts new file mode 100644 index 00000000000..41796315da5 --- /dev/null +++ b/packages/ui/src/mosaic/aio/organization-profile.messages.ts @@ -0,0 +1,8 @@ +export const orgProfileBase = { + title: 'Organization Profile', + tab: { + general: 'General', + members: 'Members', + }, + membersContent: 'Members content', +}; diff --git a/packages/ui/src/mosaic/aio/organization-profile.tsx b/packages/ui/src/mosaic/aio/organization-profile.tsx index 743da8386b5..43b277a6c03 100644 --- a/packages/ui/src/mosaic/aio/organization-profile.tsx +++ b/packages/ui/src/mosaic/aio/organization-profile.tsx @@ -1,8 +1,11 @@ import { Box } from '../components/box'; import { Tabs } from '../components/tabs'; import { OrganizationProfileGeneral } from '../panels/organization-profile-general'; +import { useMessages } from '../providers/localization-provider'; +import { orgProfileBase } from './organization-profile.messages'; export function OrganizationProfile() { + const m = useMessages('organizationProfile', orgProfileBase); return ( ({ @@ -17,12 +20,12 @@ export function OrganizationProfile() { marginBlockEnd: t.spacing(8), })} > - Organization Profile + {m.title} - General - Members + {m.tab.general} + {m.tab.members} @@ -36,7 +39,7 @@ export function OrganizationProfile() { textAlign: 'center', })} > - Members content + {m.membersContent} diff --git a/packages/ui/src/mosaic/components/__tests__/button.test.tsx b/packages/ui/src/mosaic/components/__tests__/button.test.tsx index beada857bc8..9860e2fa8fc 100644 --- a/packages/ui/src/mosaic/components/__tests__/button.test.tsx +++ b/packages/ui/src/mosaic/components/__tests__/button.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import type { MosaicAppearance } from '../../appearance'; -import { MosaicProvider } from '../../MosaicProvider'; +import { MosaicProvider } from '../../providers/mosaic-provider'; import { Button } from '../button'; /** Concatenates every inserted Emotion `