Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/i18n/src/create-i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,13 @@ export function createI18n($locale: ReadableStore<string>, options: CreateI18nOp
): Messages<B> {
const out: Record<string, unknown> = {};
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<string, unknown>, override as Overrides | undefined);
} else {
out[key] = buildEntry(locale, baseVal, override);
}
}
return out as Messages<B>;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/components/StoryEmbed.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/components/StoryPreview.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -61,8 +61,8 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
<div className='flex min-h-40 items-center justify-center p-10'>
{mounted && (
<MosaicProvider
cssLayerName='components'
appearance={{
cssLayerName: 'components',
variables,
}}
>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@clerk/i18n": "workspace:^",
"@clerk/localizations": "workspace:^",
"@clerk/shared": "workspace:^",
"@emotion/cache": "11.11.0",
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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' } }]);
});
Expand Down
37 changes: 37 additions & 0 deletions packages/ui/src/mosaic/__tests__/i18n-server.test.ts
Original file line number Diff line number Diff line change
@@ -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: {} });
});
});
31 changes: 31 additions & 0 deletions packages/ui/src/mosaic/__tests__/localization-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).general).toBe('Général');
});
});
4 changes: 2 additions & 2 deletions packages/ui/src/mosaic/__tests__/slot-recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/aio/organization-profile.messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const orgProfileBase = {
title: 'Organization Profile',
tab: {
general: 'General',
members: 'Members',
},
membersContent: 'Members content',
};
11 changes: 7 additions & 4 deletions packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
sx={t => ({
Expand All @@ -17,12 +20,12 @@ export function OrganizationProfile() {
marginBlockEnd: t.spacing(8),
})}
>
Organization Profile
{m.title}
</Box>
<Tabs.Root defaultValue='general'>
<Tabs.List>
<Tabs.Tab value='general'>General</Tabs.Tab>
<Tabs.Tab value='members'>Members</Tabs.Tab>
<Tabs.Tab value='general'>{m.tab.general}</Tabs.Tab>
<Tabs.Tab value='members'>{m.tab.members}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='general'>
<OrganizationProfileGeneral />
Expand All @@ -36,7 +39,7 @@ export function OrganizationProfile() {
textAlign: 'center',
})}
>
Members content
{m.membersContent}
</Box>
</Tabs.Panel>
</Tabs.Root>
Expand Down
7 changes: 2 additions & 5 deletions packages/ui/src/mosaic/components/__tests__/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<style>` rule (whitespace-stripped) for substring assertions. */
Expand Down Expand Up @@ -64,10 +64,7 @@ describe('Mosaic Button', () => {
},
};
render(
<MosaicProvider
appearance={appearance}
scope='signIn'
>
<MosaicProvider appearance={{ ...appearance, scope: 'signIn' }}>
<Button>Hi</Button>
</MosaicProvider>,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/components/box.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import { useMosaicTheme } from '../MosaicProvider';
import { useMosaicTheme } from '../providers/mosaic-provider';
import { Box as Primitive, type BoxProps as PrimitiveBoxProps } from '../primitives/box';
import type { SxProp } from '../slot-recipe';

Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/i18n-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { orgProfileBase } from './aio/organization-profile.messages';

/** Registry of all Mosaic UI message namespaces → base types. Extend as new messages are added. */
export type UIRegistry = {
organizationProfile: typeof orgProfileBase;
};
40 changes: 40 additions & 0 deletions packages/ui/src/mosaic/i18n/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Locale bundles shipped by @clerk/ui. Add an entry here as each locale lands.
const LOADERS: Record<string, () => Promise<{ default: Record<string, Record<string, unknown>> }>> = {
fr: () => import('../locales/fr.json'),
};

const DEFAULT_LOCALE = 'en';

function negotiate(acceptLanguage: string | null): string {
if (!acceptLanguage) return DEFAULT_LOCALE;
const available = new Set([...Object.keys(LOADERS), DEFAULT_LOCALE]);
for (const entry of acceptLanguage.split(',')) {
const lang = entry.split(';')[0].trim();
for (const candidate of [lang, lang.split('-')[0]]) {
if (available.has(candidate)) return candidate;
}
}
return DEFAULT_LOCALE;
}

export type Localization = {
locale: string;
initialMessages: Record<string, Record<string, Record<string, unknown>>>;
};

/**
* Resolve a locale and pre-load its message bundle from the Accept-Language header.
* Drop the result straight into <MosaicProvider localization={...} />.
*
* Works in any server environment — pass whatever string your framework exposes:
* Next.js: getLocalization((await headers()).get('accept-language'))
* Remix: getLocalization(request.headers.get('accept-language'))
* Express: getLocalization(req.get('accept-language') ?? null)
*/
export async function getLocalization(acceptLanguage: string | null): Promise<Localization> {
const locale = negotiate(acceptLanguage);
const loader = LOADERS[locale];
if (!loader) return { locale, initialMessages: {} };
const messages = (await loader()).default;
return { locale, initialMessages: { [locale]: messages } };
}
10 changes: 10 additions & 0 deletions packages/ui/src/mosaic/locales/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"organizationProfile": {
"title": "Profil de l'organisation",
"tab": {
"general": "Général",
"members": "Membres"
},
"membersContent": "Contenu des membres"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,49 @@ import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import React from 'react';

import { MosaicAppearanceProvider, parseMosaicAppearance } from './appearance';
import type { MosaicAppearance } from './appearance';
import { defaultMosaicVariables, resolveVariables } from './variables';
import type { MosaicTheme } from './variables';
import { MosaicAppearanceProvider, parseMosaicAppearance } from '../appearance';
import type { MosaicScopedElements } from '../appearance';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme, MosaicVariables } from '../variables';

const getInsertionPoint = (): HTMLElement | null => {
if (typeof document === 'undefined') {
return null;
}
if (typeof document === 'undefined') return null;
return document.querySelector('style#cl-mosaic-style-insertion-point');
};

const MosaicThemeContext = React.createContext<MosaicTheme | null>(null);
export const MosaicThemeContext = React.createContext<MosaicTheme | null>(null);

export interface MosaicProviderProps {
export interface AppearanceProviderProps {
children: React.ReactNode;
nonce?: string;
cssLayerName?: string;
/** Consumer overrides — `variables` (design tokens) + `elements` (per-slot styles), with optional per-flow scoping. */
appearance?: MosaicAppearance;
/** The active flow key (`'signIn'`, `'userButton'`, …) used to resolve scoped overrides. */
variables?: MosaicVariables;
elements?: MosaicScopedElements;
scope?: string;
}

export function MosaicProvider({ children, nonce, cssLayerName, appearance, scope }: MosaicProviderProps) {
const theme = React.useMemo(() => resolveVariables(defaultMosaicVariables, appearance?.variables), [appearance]);
const parsedElements = React.useMemo(() => parseMosaicAppearance(appearance, scope), [appearance, scope]);
export function AppearanceProvider({
children,
nonce,
cssLayerName,
variables,
elements,
scope,
}: AppearanceProviderProps) {
const theme = React.useMemo(() => resolveVariables(defaultMosaicVariables, variables), [variables]);
const parsedElements = React.useMemo(
() => parseMosaicAppearance({ variables, elements }, scope),
[variables, elements, scope],
);
const cache = React.useMemo(() => {
const el = getInsertionPoint();
const emotionCache = createCache({
return createCache({
key: 'cl-mosaic',
stylisPlugins: [],
prepend: cssLayerName ? false : !el,
insertionPoint: el ?? undefined,
nonce,
});

return emotionCache;
}, [nonce, cssLayerName]);

return (
Expand All @@ -55,8 +60,6 @@ export function MosaicProvider({ children, nonce, cssLayerName, appearance, scop

export function useMosaicTheme(): MosaicTheme {
const theme = React.useContext(MosaicThemeContext);
if (!theme) {
throw new Error('useMosaicTheme must be used within a MosaicProvider');
}
if (!theme) throw new Error('useMosaicTheme must be used within a MosaicProvider');
return theme;
}
Loading
Loading