Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/mosaic-provider-localization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': minor
---

Export `MosaicProvider` to enable customizing messages and icons within the new components.
2 changes: 1 addition & 1 deletion packages/mosaic/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/index.js", "maxSize": "107KB" },
{ "path": "./dist/index.js", "maxSize": "110KB" },
{ "path": "./dist/styles.css", "maxSize": "12KB" }
]
}
15 changes: 12 additions & 3 deletions packages/mosaic/src/MosaicProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,27 @@ import React from 'react';

import type { MosaicIconOverrides } from './icons/overrides';
import { MosaicIconsProvider } from './icons/overrides';
import type { MosaicLocalization } from './localization';
import { MosaicLocalizationProvider, resolveLocalization } from './localization';

export interface MosaicProviderProps {
children: React.ReactNode;
/** Per-name icon glyph overrides, applied to every `<Icon>` below this provider. */
icons?: MosaicIconOverrides;
/** Locale and strings for everything Mosaic renders; see `MosaicLocalization`. */
localization?: MosaicLocalization;
}

// Exported Mosaic components annotate a React return type on purpose. The package sets
// `jsxImportSource: '@emotion/react'`, so an inferred return leaks Emotion's `JSX.Element` into the
// published `.d.ts`, which React 19 consumers reject as a JSX element type.
export function MosaicProvider({ children, icons }: MosaicProviderProps): React.ReactElement {
const value = React.useMemo(() => icons ?? {}, [icons]);
export function MosaicProvider({ children, icons, localization }: MosaicProviderProps): React.ReactElement {
const iconsValue = React.useMemo(() => icons ?? {}, [icons]);
const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]);

return <MosaicIconsProvider value={value}>{children}</MosaicIconsProvider>;
return (
<MosaicIconsProvider value={iconsValue}>
<MosaicLocalizationProvider value={localizationValue}>{children}</MosaicLocalizationProvider>
</MosaicIconsProvider>
);
}
22 changes: 22 additions & 0 deletions packages/mosaic/src/__tests__/MosaicProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';

import type { MosaicIconOverrides } from '../icons/overrides';
import { useMosaicIcons } from '../icons/overrides';
import { useLocale, useMessages } from '../localization';
import { MosaicProvider } from '../MosaicProvider';

describe('MosaicProvider icons', () => {
Expand All @@ -27,3 +28,24 @@ describe('MosaicProvider icons', () => {
expect(result.current).toEqual({});
});
});

describe('MosaicProvider localization', () => {
it('overlays the localization prop onto the built-in messages', () => {
const { result } = renderHook(() => useMessages('userButton'), {
wrapper: ({ children }) =>
React.createElement(
MosaicProvider,
{ localization: { messages: { 'userButton.popup.label': 'Konto' } } },
children,
),
});
expect(result.current.popup.label).toBe('Konto');
});

it('exposes the locale via useLocale', () => {
const { result } = renderHook(() => useLocale(), {
wrapper: ({ children }) => React.createElement(MosaicProvider, { localization: { locale: 'de' } }, children),
});
expect(result.current).toBe('de');
});
});
104 changes: 104 additions & 0 deletions packages/mosaic/src/__tests__/localization.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { mosaicMessages, resolveLocalization, useLocale, useMessages } from '../localization';
import { MosaicProvider } from '../MosaicProvider';

describe('resolveLocalization', () => {
it('returns the base messages untouched when nothing is overridden', () => {
expect(resolveLocalization(undefined).messages).toBe(mosaicMessages);
expect(resolveLocalization({}).messages).toBe(mosaicMessages);
expect(resolveLocalization({ messages: {} }).messages).toEqual(mosaicMessages);
});

it('overlays nested overrides and keeps sibling keys', () => {
const { messages } = resolveLocalization({ messages: { userButton: { trigger: { open: 'Menü für {name}' } } } });
expect(messages.userButton.trigger.open).toBe('Menü für {name}');
expect(messages.userButton.popup.label).toBe(mosaicMessages.userButton.popup.label);
expect(messages.userProfile).toBe(mosaicMessages.userProfile);
});

it('expands dot paths', () => {
const { messages } = resolveLocalization({ messages: { 'userButton.popup.label': 'Konto' } });
expect(messages.userButton.popup.label).toBe('Konto');
expect(messages.userButton.workspaces.personal).toBe(mosaicMessages.userButton.workspaces.personal);
});

it('merges plural forms per category', () => {
const { messages } = resolveLocalization({
messages: { 'userButton.workspaces.members': { few: '{count} Mitglieder' } },
});
expect(messages.userButton.workspaces.members).toEqual({
one: '{count} member',
few: '{count} Mitglieder',
other: '{count} members',
});
});

it('lets later keys win over earlier ones when nested and dot forms overlap', () => {
const { messages } = resolveLocalization({
messages: {
userButton: { popup: { label: 'nested' } },
'userButton.popup.label': 'flat',
},
});
expect(messages.userButton.popup.label).toBe('flat');
});

it('applies overrides on top of the messages', () => {
const esES = { 'userButton.popup.label': 'Cuenta', 'userButton.trigger.open': 'Abrir menú de {name}' };
const { messages } = resolveLocalization({ messages: esES, overrides: { 'userButton.popup.label': 'Mi cuenta' } });
expect(messages.userButton.popup.label).toBe('Mi cuenta');
expect(messages.userButton.trigger.open).toBe('Abrir menú de {name}');
});

it('defaults the locale to en and passes a given one through', () => {
expect(resolveLocalization(undefined).locale).toBe('en');
expect(resolveLocalization({ locale: 'es-ES' }).locale).toBe('es-ES');
});

it('falls back to en when the locale is not a valid BCP 47 tag', () => {
expect(resolveLocalization({ locale: 'en_US' }).locale).toBe('en');
expect(resolveLocalization({ locale: '' }).locale).toBe('en');
expect(resolveLocalization({ locale: 'zz' }).locale).toBe('zz');
});

it('does not mutate the base messages', () => {
resolveLocalization({ messages: { 'userButton.popup.label': 'Konto' } });
expect(mosaicMessages.userButton.popup.label).toBe('Account');
});
});

describe('useMessages', () => {
it('returns the base namespace without a provider', () => {
const { result } = renderHook(() => useMessages('userButton'));
expect(result.current).toBe(mosaicMessages.userButton);
});

it('returns the localized namespace under a provider', () => {
const { result } = renderHook(() => useMessages('userButton'), {
wrapper: ({ children }) =>
React.createElement(
MosaicProvider,
{ localization: { messages: { 'userButton.popup.label': 'Konto' } } },
children,
),
});
expect(result.current.popup.label).toBe('Konto');
expect(result.current.trigger.open).toBe(mosaicMessages.userButton.trigger.open);
});
});

describe('useLocale', () => {
it('defaults to en', () => {
expect(renderHook(() => useLocale()).result.current).toBe('en');
});

it('reads the provider locale', () => {
const { result } = renderHook(() => useLocale(), {
wrapper: ({ children }) => React.createElement(MosaicProvider, { localization: { locale: 'de' } }, children),
});
expect(result.current).toBe('de');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,4 @@ export const reverificationMessages = {
unstable__errors__generic: 'Something went wrong. Please try again.',
/** Announced while an attempt is in flight; the button's own label stays visible. */
verifying: 'Verifying',
};
} as const;
24 changes: 13 additions & 11 deletions packages/mosaic/src/features/reverification/reverification.view.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Card } from '../../components/card';
import { Flow } from '../../components/flow';
import type { IconName } from '../../icons/registry';
import { fill } from '../../utils/messages';
import { reverificationMessages as m } from './reverification.messages';
import type { MosaicMessages } from '../../localization';
import { fill, useMessages } from '../../localization';
import type { ReverificationMethod, ReverificationOtpChannel, ReverificationViewProps } from './reverification.types';
import { ReverificationBackupCode } from './steps/reverification-backup-code';
import { ReverificationHelp } from './steps/reverification-help';
Expand All @@ -11,11 +11,7 @@ import { ReverificationOTP } from './steps/reverification-otp';
import { ReverificationPasskey } from './steps/reverification-passkey';
import { ReverificationPassword } from './steps/reverification-password';

const actions = {
secondaryActionLabel: m.footerActionLink__useAnotherMethod,
primaryActionLabel: m.formButtonPrimary,
pendingLabel: m.verifying,
};
type Messages = MosaicMessages['reverification'];

const methodIcon = {
password: 'security-lock-square',
Expand All @@ -26,7 +22,7 @@ const methodIcon = {
backup_code: 'security-phone',
} as const satisfies Record<ReverificationMethod['strategy'], IconName>;

function methodLabel(method: ReverificationMethod): string {
function methodLabel(method: ReverificationMethod, m: Messages): string {
const identifier = 'identifier' in method ? method.identifier : '';
switch (method.strategy) {
case 'password':
Expand All @@ -44,7 +40,7 @@ function methodLabel(method: ReverificationMethod): string {
}
}

function otpCopy(channel: ReverificationOtpChannel | undefined) {
function otpCopy(channel: ReverificationOtpChannel | undefined, m: Messages) {
if (channel === 'email') {
return m.emailCode;
}
Expand All @@ -55,6 +51,7 @@ function otpCopy(channel: ReverificationOtpChannel | undefined) {
}

export function ReverificationView(props: ReverificationViewProps): JSX.Element {
const m = useMessages('reverification');
const {
step,
direction,
Expand All @@ -76,7 +73,12 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element
resendRemainingSeconds,
} = props;

const otp = otpCopy(otpChannel);
const otp = otpCopy(otpChannel, m);
const actions = {
secondaryActionLabel: m.footerActionLink__useAnotherMethod,
primaryActionLabel: m.formButtonPrimary,
pendingLabel: m.verifying,
};
const hasAlternatives = methods.length > 0;
const resendLabel = otpChannel === 'phone' ? m.phoneCode.resendButton : m.emailCode.resendButton;
const resend =
Expand Down Expand Up @@ -179,7 +181,7 @@ export function ReverificationView(props: ReverificationViewProps): JSX.Element
}}
methods={methods.map(method => ({
id: method.id,
label: methodLabel(method),
label: methodLabel(method, m),
icon: methodIcon[method.strategy],
}))}
pendingMethodId={pendingMethodId}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so
* localizing this component is a matter of registering the namespace and swapping the reads for
* `useMessages('userButton', userButtonMessages)`, not of hunting the literals down first.
*/
export const userButtonMessages = {
trigger: {
open: 'Open account menu for {name}',
Expand Down Expand Up @@ -34,4 +29,4 @@ export const userButtonMessages = {
organization: 'Manage organization',
createOrganization: 'Create organization',
},
};
} as const;
Loading
Loading