diff --git a/.changeset/mosaic-provider-localization.md b/.changeset/mosaic-provider-localization.md new file mode 100644 index 00000000000..6db774ce003 --- /dev/null +++ b/.changeset/mosaic-provider-localization.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': minor +--- + +Export `MosaicProvider` to enable customizing messages and icons within the new components. diff --git a/packages/mosaic/bundlewatch.config.json b/packages/mosaic/bundlewatch.config.json index e6978792667..2325ac6ed4a 100644 --- a/packages/mosaic/bundlewatch.config.json +++ b/packages/mosaic/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/index.js", "maxSize": "107KB" }, + { "path": "./dist/index.js", "maxSize": "110KB" }, { "path": "./dist/styles.css", "maxSize": "12KB" } ] } diff --git a/packages/mosaic/src/MosaicProvider.tsx b/packages/mosaic/src/MosaicProvider.tsx index 60e2b464d01..1099b7c6b29 100644 --- a/packages/mosaic/src/MosaicProvider.tsx +++ b/packages/mosaic/src/MosaicProvider.tsx @@ -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 `` 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 {children}; + return ( + + {children} + + ); } diff --git a/packages/mosaic/src/__tests__/MosaicProvider.test.tsx b/packages/mosaic/src/__tests__/MosaicProvider.test.tsx index 3e0f17825b0..d5d4eeec4e0 100644 --- a/packages/mosaic/src/__tests__/MosaicProvider.test.tsx +++ b/packages/mosaic/src/__tests__/MosaicProvider.test.tsx @@ -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', () => { @@ -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'); + }); +}); diff --git a/packages/mosaic/src/__tests__/localization.test.tsx b/packages/mosaic/src/__tests__/localization.test.tsx new file mode 100644 index 00000000000..366af8bc304 --- /dev/null +++ b/packages/mosaic/src/__tests__/localization.test.tsx @@ -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'); + }); +}); diff --git a/packages/mosaic/src/features/reverification/reverification.messages.ts b/packages/mosaic/src/features/reverification/reverification.messages.ts index 5765e02dfc1..2b26f27a698 100644 --- a/packages/mosaic/src/features/reverification/reverification.messages.ts +++ b/packages/mosaic/src/features/reverification/reverification.messages.ts @@ -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; diff --git a/packages/mosaic/src/features/reverification/reverification.view.tsx b/packages/mosaic/src/features/reverification/reverification.view.tsx index 8f85685ab71..49850e3e972 100644 --- a/packages/mosaic/src/features/reverification/reverification.view.tsx +++ b/packages/mosaic/src/features/reverification/reverification.view.tsx @@ -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'; @@ -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', @@ -26,7 +22,7 @@ const methodIcon = { backup_code: 'security-phone', } as const satisfies Record; -function methodLabel(method: ReverificationMethod): string { +function methodLabel(method: ReverificationMethod, m: Messages): string { const identifier = 'identifier' in method ? method.identifier : ''; switch (method.strategy) { case 'password': @@ -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; } @@ -55,6 +51,7 @@ function otpCopy(channel: ReverificationOtpChannel | undefined) { } export function ReverificationView(props: ReverificationViewProps): JSX.Element { + const m = useMessages('reverification'); const { step, direction, @@ -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 = @@ -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} diff --git a/packages/mosaic/src/features/user-button/user-button.messages.ts b/packages/mosaic/src/features/user-button/user-button.messages.ts index 609e4fcb1c9..52d8e876d56 100644 --- a/packages/mosaic/src/features/user-button/user-button.messages.ts +++ b/packages/mosaic/src/features/user-button/user-button.messages.ts @@ -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}', @@ -34,4 +29,4 @@ export const userButtonMessages = { organization: 'Manage organization', createOrganization: 'Create organization', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-button/user-button.view.tsx b/packages/mosaic/src/features/user-button/user-button.view.tsx index d3e8594244c..3b27b416d56 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -18,14 +18,14 @@ import { Popover } from '../../components/popover'; import { scrollAreaViewport } from '../../components/scroll-area'; import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; +import type { MosaicMessages } from '../../localization'; +import { fill, plural, useLocale, useMessages } from '../../localization'; import { applyOrder } from '../../utils/apply-order'; import { focusOutline } from '../../utils/focus-outline.styles'; -import { fill, plural } from '../../utils/messages'; import { rtl } from '../../utils/rtl.styles'; import { truncationStyles } from '../../utils/typography.styles'; import type { UserButtonLayout } from './user-button.layout'; import { resolveUserButtonLayout } from './user-button.layout'; -import { userButtonMessages as m } from './user-button.messages'; import { styles } from './user-button.styles'; import type { UserButtonBrandingProps, @@ -102,12 +102,12 @@ type ActiveWorkspace = * What the surface leads with: named in the trigger and headed in the popup, so the two always * agree. An organization-led surface with no org and no personal workspace is no selection. */ -function leadWorkspace({ - layout, - activeOrganization, - activeSession, - hidePersonal, -}: UserButtonContextValue): ActiveWorkspace { +type Messages = MosaicMessages['userButton']; + +function leadWorkspace( + { layout, activeOrganization, activeSession, hidePersonal }: UserButtonContextValue, + m: Messages, +): ActiveWorkspace { if (layout.leadWith === 'organization') { if (activeOrganization) { return { @@ -125,10 +125,10 @@ function leadWorkspace({ return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' }; } -function membershipSubtitle(membership: UserButtonMembership): string { +function membershipSubtitle(membership: UserButtonMembership, m: Messages, locale: string): string { const parts: string[] = []; if (membership.membersCount !== undefined) { - parts.push(plural(m.workspaces.members, membership.membersCount)); + parts.push(plural(m.workspaces.members, membership.membersCount, locale)); } if (membership.planLabel) { parts.push(membership.planLabel); @@ -226,6 +226,7 @@ function SwitcherRow({ busy, disabled, }: SwitcherRowProps) { + const m = useMessages('userButton'); // Selecting what is already selected does nothing, so the active row is not a button at all. A // row that is merely waiting stays one, disabled. const select = active ? undefined : onSelect; @@ -368,10 +369,12 @@ function HeaderActionButton({ label, icon, onClick, busyKey }: HeaderAction) { /** The active workspace: who you are signed in as, and what you can do about it. */ function Header() { + const m = useMessages('userButton'); + const locale = useLocale(); const data = useUserButtonContext(); const signOutSession = data.onSignOutSession; const { sessionId, identifier } = data.activeSession; - const workspace = leadWorkspace(data); + const workspace = leadWorkspace(data, m); const { name, imageUrl, shape } = workspace; const organization = workspace.kind === 'organization' ? workspace.organization : undefined; // An account with no name is titled by its identifier, and repeating it underneath says nothing. @@ -379,7 +382,7 @@ function Header() { const accountSubtitle = identifier === name ? '' : identifier; const subtitle = workspace.kind === 'organization' - ? membershipSubtitle(workspace.organization) + ? membershipSubtitle(workspace.organization, m, locale) : workspace.kind === 'user' ? accountSubtitle : ''; @@ -479,6 +482,7 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA * below carries the ones that act on every account. */ function OrganizationsHeading() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const signOutSession = data.onSignOutSession; const { identifier, sessionId } = data.activeSession; @@ -554,6 +558,7 @@ function MembershipRow({ membership, active, onSelect }: MembershipRowProps) { * what they are about. */ function PersonalRow() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const selectOrganization = data.onSelectOrganization; const { busy, disabled } = useBusy(userButtonBusyKeys.selectOrganization(null)); @@ -606,6 +611,7 @@ interface PendingRowProps { /** A workspace on offer: joined from its own trailing button rather than by clicking the row. */ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: PendingRowProps) { + const m = useMessages('userButton'); const { busy, disabled } = useBusy(busyKey); // The button reads the same on every offer and the workspace it acts on is the label beside it, // so pressing tab through the list gives no way to tell them apart without this. @@ -647,6 +653,7 @@ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: Pe /** What the active account has been asked to join but has not joined yet. */ function PendingRows() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const acceptSuggestion = data.onAcceptSuggestion; const acceptInvitation = data.onAcceptInvitation; @@ -754,6 +761,7 @@ function SessionMenuItem({ session, active }: { session: UserButtonSession; acti * organizations heading carries the spinner for what its own `⋯` opens. */ function SwitchAccountRow() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const addAccount = data.onAddAccount; const { pendingKey } = data; @@ -835,6 +843,7 @@ function SwitchAccountRow() { /** Holds the organization list's place until its first page lands. */ function OrganizationListLoadingRow() { + const m = useMessages('userButton'); return ( // Plain text rather than a live region: it mounts with its copy already in it, so there is no // change for one to report, and the popup it lands in is read on open either way. @@ -857,6 +866,7 @@ function OrganizationListLoadingRow() { * to manage and sign out of itself. */ function OrganizationSection() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const { showOrganizations, showOrganizationsHeading } = data.layout; @@ -924,6 +934,7 @@ interface FooterRow { /** The actions that close out the surface. */ function Footer() { + const m = useMessages('userButton'); const data = useUserButtonContext(); const builtIn: FooterRow[] = []; @@ -1070,8 +1081,9 @@ export function UserButtonTrigger({ renderTriggerLabel = true, renderTriggerBadge = true, }: UserButtonTriggerProps = {}): ReactElement { + const m = useMessages('userButton'); const data = useUserButtonContext(); - const workspace = leadWorkspace(data); + const workspace = leadWorkspace(data, m); const { name, imageUrl, shape } = workspace; const planLabel = renderTriggerBadge && workspace.kind === 'organization' ? workspace.organization.planLabel : undefined; @@ -1110,6 +1122,7 @@ export function UserButtonTrigger({ /** The popover surface: header, organizations, and footer. */ export function UserButtonPopup(): ReactElement { + const m = useMessages('userButton'); const { renderBranding } = useUserButtonContext(); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 2097abcb1fd..985d6683d9a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -1,9 +1,4 @@ /** - * 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('userProfileAccountSection', userProfileAccountSectionMessages)`, not of hunting the - * literals down first. - * * Email and phone keep separate keys rather than sharing one templated string: a locale that * inflects around the noun cannot build either from the other. */ @@ -80,4 +75,4 @@ export const userProfileAccountSectionMessages = { cancel: 'Cancel', }, }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 6a822e98f36..6cec82fe9fe 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -2,7 +2,7 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, @@ -87,6 +87,7 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const m = useMessages('userProfileAccountSection'); const phoneRow = ( { return Promise.reject(new Error('Add email callbacks are missing')); } -function errorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : m.error; +function errorMessage(cause: unknown, fallback: string): string | undefined { + if (cause === undefined) { + return undefined; + } + return cause instanceof Error ? cause.message : fallback; } const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; @@ -47,7 +50,7 @@ const machine = createMachine({ onVerify: missingDependency, emailAddress: '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, }, states: { @@ -58,7 +61,7 @@ const machine = createMachine({ actions: assign(context => ({ emailAddress: context.initialEmailAddress ?? '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, })), }, @@ -67,8 +70,8 @@ const machine = createMachine({ email: { on: { CANCEL: 'idle', - TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, errorMessage: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, error: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, }, }, sending: { @@ -76,9 +79,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'email', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -89,12 +90,12 @@ const machine = createMachine({ RESEND: { target: 'resending', guard: context => context.resendSeconds === 0, - actions: assign(() => ({ errorMessage: undefined })), + actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, SUBMIT: { target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), }, }, }, @@ -103,9 +104,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -115,9 +114,7 @@ const machine = createMachine({ onDone: 'idle', onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -127,6 +124,7 @@ const machine = createMachine({ export function useUserProfileAddEmailController( options: UserProfileAddEmailControllerOptions, ): UserProfileAddEmailDialogProps { + const m = useMessages('userProfileAddEmail'); const [snapshot, send] = useMachine(machine, { context: options }); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; @@ -148,7 +146,7 @@ export function useUserProfileAddEmailController( : 'email', emailAddress: snapshot.context.emailAddress, code: snapshot.context.code, - errorMessage: snapshot.context.errorMessage, + errorMessage: errorMessage(snapshot.context.error, m.error), isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), onEmailAddressChange: value => send({ type: 'TYPE_EMAIL', value }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index 62cb7f95feb..50d1237fa28 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -10,9 +10,8 @@ import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Input } from '../../../components/input'; import { Otp } from '../../../components/otp'; -import { fill, rich } from '../../../utils/messages'; +import { fill, rich, useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; export interface UserProfileAddEmailDialogProps { open: boolean; @@ -96,6 +95,7 @@ interface EnterEmailStepProps { } function EnterEmailStep(props: EnterEmailStepProps) { + const m = useMessages('userProfileAddEmail'); const emailFormId = useId(); const handleSubmit = (event: FormEvent) => { @@ -161,6 +161,7 @@ interface VerifyEmailStepProps { } function VerifyEmailStep(props: VerifyEmailStepProps) { + const m = useMessages('userProfileAddEmail'); const verifyFormId = useId(); const handleSubmit = (event: FormEvent) => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts index cfcad0728a0..d665475f099 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts @@ -18,4 +18,4 @@ export const userProfileAddEmailMessages = { resending: 'Sending a new code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts index 6f390d2d032..b5546cba3a7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react'; +import { useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { UserProfileAddPhoneDialogProps } from './user-profile-add-phone.dialog'; -import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; export interface UserProfileAddPhoneControllerOptions { initialPhoneNumber?: string; @@ -14,7 +14,7 @@ export interface UserProfileAddPhoneControllerOptions { interface Context extends UserProfileAddPhoneControllerOptions { phoneNumber: string; code: string; - errorMessage: string | undefined; + error: unknown; resendSeconds: number; } @@ -33,8 +33,11 @@ function missingDependency(): Promise { return Promise.reject(new Error('Add phone callbacks are missing')); } -function errorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : m.error; +function errorMessage(cause: unknown, fallback: string): string | undefined { + if (cause === undefined) { + return undefined; + } + return cause instanceof Error ? cause.message : fallback; } const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; @@ -47,7 +50,7 @@ const machine = createMachine({ onVerify: missingDependency, phoneNumber: '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, }, states: { @@ -58,7 +61,7 @@ const machine = createMachine({ actions: assign(context => ({ phoneNumber: context.initialPhoneNumber ?? '', code: '', - errorMessage: undefined, + error: undefined, resendSeconds: 0, })), }, @@ -67,8 +70,8 @@ const machine = createMachine({ phone: { on: { CANCEL: 'idle', - TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, errorMessage: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, error: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, }, }, sending: { @@ -76,9 +79,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'phone', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -89,12 +90,12 @@ const machine = createMachine({ RESEND: { target: 'resending', guard: context => context.resendSeconds === 0, - actions: assign(() => ({ errorMessage: undefined })), + actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, SUBMIT: { target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), }, }, }, @@ -103,9 +104,7 @@ const machine = createMachine({ onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -115,9 +114,7 @@ const machine = createMachine({ onDone: 'idle', onError: { target: 'verify', - actions: assign((_, event) => ({ - errorMessage: errorMessage(event.error), - })), + actions: assign((_, event) => ({ error: event.error })), }, }), }, @@ -127,6 +124,7 @@ const machine = createMachine({ export function useUserProfileAddPhoneController( options: UserProfileAddPhoneControllerOptions, ): UserProfileAddPhoneDialogProps { + const m = useMessages('userProfileAddPhone'); const [snapshot, send] = useMachine(machine, { context: options }); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; @@ -148,7 +146,7 @@ export function useUserProfileAddPhoneController( : 'phone', phoneNumber: snapshot.context.phoneNumber, code: snapshot.context.code, - errorMessage: snapshot.context.errorMessage, + errorMessage: errorMessage(snapshot.context.error, m.error), isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), onPhoneNumberChange: value => send({ type: 'TYPE_PHONE', value }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx index 815f67d9f71..7296eb59bdc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx @@ -11,9 +11,8 @@ import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Otp } from '../../../components/otp'; import { PhoneInput } from '../../../components/phone-input'; -import { fill, rich } from '../../../utils/messages'; +import { fill, rich, useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; export interface UserProfileAddPhoneDialogProps { open: boolean; @@ -97,6 +96,7 @@ interface EnterPhoneStepProps { } function EnterPhoneStep(props: EnterPhoneStepProps) { + const m = useMessages('userProfileAddPhone'); const phoneFormId = useId(); const handleSubmit = (event: FormEvent) => { @@ -162,6 +162,7 @@ interface VerifyPhoneStepProps { } function VerifyPhoneStep(props: VerifyPhoneStepProps) { + const m = useMessages('userProfileAddPhone'); const verifyFormId = useId(); const handleSubmit = (event: FormEvent) => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts index d9883904dff..89dcf730e81 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts @@ -18,4 +18,4 @@ export const userProfileAddPhoneMessages = { resending: 'Sending a new code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index 1991d2ce147..9f950b2461b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -4,11 +4,10 @@ import { Badge } from '../../../components/badge'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; -import { fill } from '../../../utils/messages'; +import { fill, useMessages } from '../../../localization'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; export interface UserProfileContactListRowViewProps { addAction?: ReactNode; @@ -31,6 +30,7 @@ export function UserProfileContactListRowView({ onRemove, addAction, }: UserProfileContactListRowViewProps) { + const m = useMessages('userProfileAccountSection'); const emptyDescription = m[kind].empty; return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx index b019cad3c40..296335e1604 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx @@ -3,8 +3,8 @@ import type { ReactNode } from 'react'; import { Badge } from '../../../components/badge'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; export interface UserProfileContactRowViewProps { kind: 'email' | 'phone'; @@ -23,6 +23,7 @@ export function UserProfileContactRowView({ onManage, addAction, }: UserProfileContactRowViewProps) { + const m = useMessages('userProfileAccountSection'); const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; const emptyDescription = m[kind].empty; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 8c8fe962f77..589a41516b5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import type { UserProfileFormError, UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -54,6 +54,7 @@ export function UserProfileEditNameDialog({ error, onSubmit, }: UserProfileEditNameDialogProps) { + const m = useMessages('userProfileAccountSection'); const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index 32b48319f79..7fa55d04cb8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import type { UserProfileFormError } from './user-profile-account-section.types'; export type UserProfileEditUsernameField = 'username'; @@ -36,6 +36,7 @@ export function UserProfileEditUsernameDialog({ error, onSubmit, }: UserProfileEditUsernameDialogProps) { + const m = useMessages('userProfileAccountSection'); const formId = useId(); const usernameRef = useRef(null); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index 7d2690c775c..2e8ac6b944f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -4,8 +4,7 @@ import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; -import { fill } from '../../../utils/messages'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { fill, useMessages } from '../../../localization'; import type { UserProfileEmail } from './user-profile-account-section.types'; import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; @@ -36,6 +35,7 @@ export function UserProfileEmailRowView({ onSetPrimaryEmail, onRemoveEmail, }: UserProfileEmailRowViewProps) { + const m = useMessages('userProfileAccountSection'); const addEmailAction = onSendEmailCode && onVerifyEmailCode ? ( @@ -59,6 +60,7 @@ function EditName({ lastNameAttribute?: UserProfileNameAttribute; onSubmit: (value: UserProfileEditNameValue) => Promise; }) { + const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index dfcff9b2c43..545c25899c4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -5,8 +5,7 @@ import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; -import { fill } from '../../../utils/messages'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { fill, useMessages } from '../../../localization'; import type { UserProfilePhone } from './user-profile-account-section.types'; import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; @@ -35,6 +34,7 @@ export function UserProfilePhoneRowView({ onSetPrimaryPhone, onRemovePhone, }: UserProfilePhoneRowViewProps) { + const m = useMessages('userProfileAccountSection'); const addPhoneAction = onSendPhoneCode && onVerifyPhoneCode ? ( (); const displayedError = errorMessage ?? rejectionError; const initials = name @@ -93,6 +94,7 @@ function ProfilePictureActions({ canChange: boolean; onRemove?: () => void; }) { + const m = useMessages('userProfileAccountSection'); const { openFilePicker } = FileUpload.useFileUpload(); const actions: UserProfileMenuAction[] = []; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index 0867aa8cbe9..5e1aefc11f5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -1,6 +1,6 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; +import { useMessages } from '../../../localization'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; @@ -10,6 +10,7 @@ export interface UserProfileUsernameRowViewProps { } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { + const m = useMessages('userProfileAccountSection'); return ( @@ -31,6 +32,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs } function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { + const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx index a57288395db..daac5c2021f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx @@ -7,9 +7,8 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Spinner } from '../../../components/spinner'; import { Text } from '../../../components/text'; -import { fill, rich } from '../../../utils/messages'; +import { fill, rich, useMessages } from '../../../localization'; import { styles as profileStyles } from '../user-profile-profile-panel.styles'; -import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; import { styles } from './user-profile-verify-email-link.styles'; export interface UserProfileVerifyEmailLinkDialogProps { @@ -33,6 +32,7 @@ export function UserProfileVerifyEmailLinkDialog({ resendSeconds = 0, errorMessage, }: UserProfileVerifyEmailLinkDialogProps) { + const m = useMessages('userProfileVerifyEmailLink'); return ( void; onRemove?: (account: UserProfileConnectedAccount) => void; }) { + const m = useMessages('userProfileConnectedAccounts'); const iconUrl = account.iconUrl?.trim(); const actions: UserProfileMenuAction[] = []; if (account.status === 'reconnect' && onReconnect) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx index cb763c6276b..67c7729039e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx @@ -2,9 +2,8 @@ import { useMemo } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; -import { fill } from '../../utils/messages'; +import { fill, useMessages } from '../../localization'; import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view'; -import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; export interface UserProfileConnectionProvider { id: string; @@ -36,6 +35,7 @@ export function UserProfileConnectedAccountsSectionView({ onReconnect, onRemove, }: UserProfileConnectedAccountsSectionViewProps) { + const m = useMessages('userProfileConnectedAccounts'); const removeAccount = useMemo(() => Confirmation.createHandle(), []); const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts index acf778b26df..a31102ac5fe 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts @@ -13,4 +13,4 @@ export const userProfileConnectedAccountsMessages = { confirm: 'Remove', cancel: 'Cancel', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts index 9935c7c8c54..bc32d864fe1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.messages.ts @@ -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('userProfileDeleteSection', userProfileDeleteSectionMessages)`, not of hunting the literals down first. - */ export const userProfileDeleteSectionMessages = { sectionTitle: 'Danger zone', sectionLabel: 'Delete account', @@ -13,4 +8,4 @@ export const userProfileDeleteSectionMessages = { fieldPlaceholder: 'Delete account', actionLabel: 'Delete account', cancelLabel: 'Cancel', -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index a6e27158129..d9c00997b64 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -1,9 +1,8 @@ import { Destructive } from '../../../blocks/destructive'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { fill } from '../../../utils/messages'; +import { fill, useMessages } from '../../../localization'; import { useUserProfileDeleteSectionController } from './user-profile-delete-section.controller'; -import { userProfileDeleteSectionMessages as m } from './user-profile-delete-section.messages'; export interface UserProfileDeleteSectionViewProps { /** @@ -14,6 +13,7 @@ export interface UserProfileDeleteSectionViewProps { } export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSectionViewProps) { + const m = useMessages('userProfileDeleteSection'); const { isOpen, onOpenChange, onConfirm, isDeleting, errorMessage } = useUserProfileDeleteSectionController({ onDelete, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx index ee08ce08d82..3bcc4f185c2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx @@ -5,8 +5,7 @@ import { Button } from '../../../components/button'; import { Icon, IconFrame } from '../../../components/icon'; import { Section } from '../../../components/section'; import { Spinner } from '../../../components/spinner'; -import { fill } from '../../../utils/messages'; -import { userProfileEnterpriseAccountsMessages as m } from './user-profile-enterprise-accounts-section.messages'; +import { fill, useMessages } from '../../../localization'; import { styles } from './user-profile-enterprise-accounts-section.styles'; import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-accounts-section.types'; @@ -21,6 +20,7 @@ export function UserProfileEnterpriseAccountRowView({ isPending?: boolean; disabled?: boolean; }) { + const m = useMessages('userProfileEnterpriseAccountsSection'); const iconUrl = account.iconUrl?.trim(); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts index 095f8c062d3..a601b7bada9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts @@ -3,4 +3,4 @@ export const userProfileEnterpriseAccountsMessages = { connect: 'Connect', connectProvider: 'Connect {provider}', requiresAction: 'Requires action', -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx index 0c9923801ad..c6321e37602 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx @@ -1,6 +1,6 @@ import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; import { UserProfileEnterpriseAccountRowView } from './user-profile-enterprise-account-row.view'; -import { userProfileEnterpriseAccountsMessages as m } from './user-profile-enterprise-accounts-section.messages'; import type { UserProfileEnterpriseAccount, UserProfileEnterpriseConnection, @@ -19,6 +19,7 @@ export function UserProfileEnterpriseAccountsSectionView({ pendingConnectionId, onConnect, }: UserProfileEnterpriseAccountsSectionViewProps) { + const m = useMessages('userProfileEnterpriseAccountsSection'); if (accounts.length === 0 && (connections.length === 0 || !onConnect)) { return null; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 2c15f9fc9fb..2bc37b8e486 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -100,39 +100,14 @@ describe('userProfileEditPasswordMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => Promise.reject(new Error('Incorrect password.'))); + const failure = new Error('Incorrect password.'); + const actor = start(() => Promise.reject(failure)); fill(actor); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); expect(actor.getSnapshot().context.newPassword).toBe('new-secret-123'); - expect(actor.getSnapshot().context.error).toEqual({ message: 'Incorrect password.', fields: undefined }); - }); - - it('carries field copy through when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your password could not be updated.', { - newPassword: 'Your password must contain 8 or more characters.', - }); - const actor = start(() => Promise.reject(failure)); - fill(actor); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ - newPassword: 'Your password must contain 8 or more characters.', - }), - ); - }); - - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); - fill(actor); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); + expect(actor.getSnapshot().context.error).toBe(failure); }); it('refuses to save until both halves match', () => { @@ -234,6 +209,39 @@ describe('useUserProfileEditPasswordController', () => { expect(result.current.error).toBeUndefined(); }); + it('carries field copy through when the rejection names the control', async () => { + const failure = new UserProfileSaveError('Your password could not be updated.', { + newPassword: 'Your password must contain 8 or more characters.', + }); + const { result } = renderController(() => Promise.reject(failure)); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + act(() => result.current.onSubmit()); + + await waitFor(() => + expect(result.current.error).toEqual({ + message: 'Your password could not be updated.', + fields: { newPassword: 'Your password must contain 8 or more characters.' }, + }), + ); + }); + + it('falls back to generic copy when the rejection is not an Error', async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test + const { result } = renderController(() => Promise.reject('nope')); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onCurrentPasswordChange('old-secret')); + act(() => result.current.onNewPasswordChange('new-secret-123')); + act(() => result.current.onConfirmPasswordChange('new-secret-123')); + act(() => result.current.onSubmit()); + + await waitFor(() => expect(result.current.error?.message).toBe('Something went wrong. Please try again.')); + }); + it('keeps a failed save visible next to a fresh mismatch', async () => { const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts index 1a143f903ad..e6479db93ac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.ts @@ -1,8 +1,8 @@ +import { useMessages } from '../../../localization'; import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import type { UserProfileEditPasswordField, UserProfileEditPasswordValue } from './user-profile-password-section.types'; export interface UserProfileEditPasswordContext { @@ -12,7 +12,7 @@ export interface UserProfileEditPasswordContext { newPassword: string; confirmPassword: string; signOutOfOtherSessions: boolean; - error: UserProfileFormError | undefined; + error: unknown; } export type UserProfileEditPasswordEvent = @@ -48,14 +48,17 @@ export function isSaveable(context: UserProfileEditPasswordContext): boolean { ); } -function toFormError(cause: unknown): UserProfileFormError { +function toFormError(cause: unknown, fallback: string): UserProfileFormError | undefined { + if (cause === undefined) { + return undefined; + } if (cause instanceof UserProfileSaveError) { return { message: cause.message, fields: cause.fields }; } if (cause instanceof Error) { return { message: cause.message }; } - return { message: m.errors.generic }; + return { message: fallback }; } export const userProfileEditPasswordMachine = createMachine({ @@ -92,7 +95,7 @@ export const userProfileEditPasswordMachine = createMachine({ onDone: { target: 'idle', actions: assign(() => emptyFields) }, onError: { target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + actions: assign((_, event) => ({ error: event.error })), }, }, ), @@ -126,13 +129,15 @@ export function useUserProfileEditPasswordController({ requiresCurrentPassword = false, onSubmit, }: UserProfileEditPasswordControllerOptions): UserProfileEditPasswordController { + const m = useMessages('userProfilePasswordSection'); const [snapshot, send] = useMachine(userProfileEditPasswordMachine, { context: { savePassword: onSubmit, requiresCurrentPassword }, }); const { context } = snapshot; + const saveError = toFormError(context.error, m.errors.generic); const error = passwordsMismatch(context) - ? { ...context.error, fields: { ...context.error?.fields, confirmPassword: m.errors.mismatch } } - : context.error; + ? { ...saveError, fields: { ...saveError?.fields, confirmPassword: m.errors.mismatch } } + : saveError; return { isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx index 1252a652728..77ae0b09c8c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog.tsx @@ -11,8 +11,8 @@ import { Field } from '../../../components/field'; import { Icon } from '../../../components/icon'; import { InputGroup } from '../../../components/input-group'; import { Text } from '../../../components/text'; +import { useMessages } from '../../../localization'; import type { UserProfileFormError } from '../user-profile-account-section/user-profile-account-section.types'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordField } from './user-profile-password-section.types'; @@ -55,6 +55,7 @@ export function UserProfileEditPasswordDialog({ error, onSubmit, }: UserProfileEditPasswordDialogProps) { + const m = useMessages('userProfilePasswordSection'); const formId = useId(); const signOutId = useId(); const signOutDescriptionId = useId(); @@ -202,6 +203,7 @@ function PasswordField({ value: string; onChange: (value: string) => void; }) { + const m = useMessages('userProfilePasswordSection'); const [visible, setVisible] = useState(false); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 6c7ef41f894..05c8815c162 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -4,10 +4,9 @@ import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; import { Text } from '../../../components/text'; -import { fill } from '../../../utils/messages'; +import { fill, useMessages } from '../../../localization'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordValue, @@ -21,6 +20,7 @@ export function UserProfilePasswordRowView({ managedBy, onSubmitPassword, }: Omit) { + const m = useMessages('userProfilePasswordSection'); return ( @@ -47,6 +47,7 @@ export function UserProfilePasswordRowView({ } function ManagedByLabel({ name, iconUrl }: UserProfilePasswordManagedBy) { + const m = useMessages('userProfilePasswordSection'); return (
{iconUrl ? ( @@ -83,6 +84,7 @@ function EditPassword({ requiresCurrentPassword: boolean; onSubmit: (value: UserProfileEditPasswordValue) => Promise; }) { + const m = useMessages('userProfilePasswordSection'); const controller = useUserProfileEditPasswordController({ requiresCurrentPassword: hasPassword && requiresCurrentPassword, onSubmit, diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts index 193efff613c..1ad7b77b90b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.messages.ts @@ -26,4 +26,4 @@ export const userProfilePasswordSectionMessages = { mismatch: "Passwords don't match.", generic: 'Something went wrong. Please try again.', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index c227dc80971..51d736bcdd9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -1,6 +1,6 @@ import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; import { UserProfilePasswordRowView } from './user-profile-password-row.view'; -import { userProfilePasswordSectionMessages as m } from './user-profile-password-section.messages'; import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; export type { @@ -11,19 +11,21 @@ export type { } from './user-profile-password-section.types'; export function UserProfilePasswordSectionView({ - sectionTitle = m.sectionTitle, + sectionTitle, hasPassword = false, requiresCurrentPassword = false, managedBy, onSubmitPassword, }: UserProfilePasswordSectionViewProps) { + const m = useMessages('userProfilePasswordSection'); + const title = sectionTitle ?? m.sectionTitle; if (!hasPassword && !managedBy && !onSubmitPassword) { return null; } return ( - - {sectionTitle ? {sectionTitle} : null} + + {title ? {title} : null} void; onRemove?: (wallet: UserProfileWeb3Wallet) => void; }) { + const m = useMessages('userProfileWeb3Wallets'); const iconUrl = wallet.iconUrl?.trim(); const linkedWallet = 'address' in wallet ? wallet : undefined; const address = linkedWallet?.address; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index 7cd546b46ba..f07a019108f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -2,10 +2,9 @@ import { useMemo } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; -import { fill } from '../../utils/messages'; +import { fill, useMessages } from '../../localization'; import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; -import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; export interface UserProfileWeb3Provider { id: string; @@ -40,6 +39,7 @@ export function UserProfileWeb3WalletsSectionView({ onSetPrimary, onRemove, }: UserProfileWeb3WalletsSectionViewProps) { + const m = useMessages('userProfileWeb3Wallets'); const removeWallet = useMemo(() => Confirmation.createHandle(), []); const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); @@ -73,7 +73,11 @@ export function UserProfileWeb3WalletsSectionView({ + fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { + wallet: truncateWithEndVisible(wallet.address, 13, 4), + }) + } actionLabel={m.removeDialog.confirm} cancelLabel={m.removeDialog.cancel} onConfirm={wallet => onRemove(wallet.id)} @@ -82,9 +86,3 @@ export function UserProfileWeb3WalletsSectionView({ ); } - -function describeWalletRemoval(wallet: UserProfileWeb3Wallet) { - return fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { - wallet: truncateWithEndVisible(wallet.address, 13, 4), - }); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index 451dd43ef11..abdfb66e137 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -15,4 +15,4 @@ export const userProfileWeb3WalletsMessages = { confirm: 'Remove', cancel: 'Cancel', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile.messages.ts b/packages/mosaic/src/features/user-profile/user-profile.messages.ts index 200a5b1a3c8..7f95082be45 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.messages.ts @@ -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('userProfile', userProfileMessages)`, not of hunting the literals down first. - */ export const userProfileMessages = { /** Names the surface: its navigation landmark, and the dialog it opens in. */ label: 'User profile', @@ -12,4 +7,4 @@ export const userProfileMessages = { billing: 'Billing', apiKeys: 'API Keys', }, -}; +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index 9abdaf968cd..3e200f1eec1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { Icon } from '../../components/icon'; import type { ProfileRootProps } from '../../components/profile'; import { Profile } from '../../components/profile'; +import { useMessages } from '../../localization'; import { getAvailableUserProfilePages, resolveUserProfilePages, USER_PROFILE_PAGE_ICONS } from './user-profile.layout'; -import { userProfileMessages as m } from './user-profile.messages'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view'; import { UserProfileBillingPanelView } from './user-profile-billing-panel.view'; @@ -12,7 +12,7 @@ import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; export interface UserProfileViewProps extends Omit { - /** Names the surface, and the dialog it opens in. Defaults to English; pass a localized string once one is available. */ + /** Names the surface, and the dialog it opens in. Defaults to the `userProfile.label` message. */ label?: string; /** The open page: a built-in page's id, or a custom page's `path`. */ activePage: UserProfilePageId | (string & {}); @@ -47,9 +47,10 @@ function BuiltInPage({ id, pages }: { id: UserProfilePageId; pages: UserProfileP * surface blank. */ export const UserProfileView = React.forwardRef(function UserProfileView( - { activePage, pages, customPages, pageOrder, onPageChange, label = m.label, ...rest }, + { activePage, pages, customPages, pageOrder, onPageChange, label, ...rest }, ref, ) { + const m = useMessages('userProfile'); const entries = resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder); const resolvedPage = entries.some(entry => entry.id === activePage) ? activePage : entries[0].id; @@ -60,7 +61,7 @@ export const UserProfileView = React.forwardRef - {label} + {label ?? m.label} {entries.map(entry => ( >; + +type Leaf = string | { readonly other: string }; + +type Nested = { + [K in keyof T]?: T[K] extends string ? string : T[K] extends Leaf ? PluralOverride : Nested; +}; + +type Paths = { + [K in keyof T & string]: T[K] extends Leaf ? `${Prefix}${K}` : Paths; +}[keyof T & string]; + +type At = P extends `${infer Head}.${infer Rest}` + ? Head extends keyof T + ? At + : never + : P extends keyof T + ? T[P] + : never; + +type Flat = { [P in Paths]?: At extends string ? string : PluralOverride }; + +/** + * A set of strings for Mosaic, as nested objects or dot paths. Every key is optional and anything + * omitted falls back to the built-in English. Plural leaves merge per category. + */ +export type MosaicCatalog = Nested & Flat; + +export interface MosaicLocalization { + /** BCP 47 tag that picks plural forms. Defaults to `en`. */ + locale?: string; + /** The catalog for `locale`, applied over English. */ + messages?: MosaicCatalog; + /** Sparse changes applied over `messages`. */ + overrides?: MosaicCatalog; +} diff --git a/packages/mosaic/src/localization/context.tsx b/packages/mosaic/src/localization/context.tsx new file mode 100644 index 00000000000..7f4ae8575a2 --- /dev/null +++ b/packages/mosaic/src/localization/context.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import type { MosaicLocalization } from './catalog'; +import type { MosaicMessages } from './registry'; +import { mosaicMessages } from './registry'; + +interface MosaicLocalizationValue { + messages: MosaicMessages; + locale: string; +} + +const MosaicLocalizationContext = React.createContext({ + messages: mosaicMessages, + locale: 'en', +}); + +export const MosaicLocalizationProvider = MosaicLocalizationContext.Provider; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function merge(base: unknown, overrides: unknown): unknown { + if (!isRecord(base) || !isRecord(overrides)) { + return overrides ?? base; + } + let result = base; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + continue; + } + const [head, ...rest] = key.split('.'); + const nested = rest.length > 0 ? { [rest.join('.')]: value } : value; + result = { ...result, [head]: merge(result[head], nested) }; + } + return result; +} + +function resolveLocale(locale: string | undefined): string { + if (!locale) { + return 'en'; + } + try { + Intl.getCanonicalLocales(locale); + return locale; + } catch { + return 'en'; + } +} + +export function resolveLocalization(localization: MosaicLocalization | undefined): MosaicLocalizationValue { + const layers = [localization?.messages, localization?.overrides]; + return { + messages: layers.reduce(merge, mosaicMessages) as MosaicMessages, + locale: resolveLocale(localization?.locale), + }; +} + +export function useMessages(namespace: Namespace): MosaicMessages[Namespace] { + return React.useContext(MosaicLocalizationContext).messages[namespace]; +} + +export function useLocale(): string { + return React.useContext(MosaicLocalizationContext).locale; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts new file mode 100644 index 00000000000..fbfc32e732c --- /dev/null +++ b/packages/mosaic/src/localization/index.ts @@ -0,0 +1,6 @@ +export type { MosaicCatalog, MosaicLocalization } from './catalog'; +export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; +export type { MosaicMessages } from './registry'; +export { mosaicMessages } from './registry'; +export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; +export { fill, plural, rich } from './messages'; diff --git a/packages/mosaic/src/localization/localization.test-d.ts b/packages/mosaic/src/localization/localization.test-d.ts new file mode 100644 index 00000000000..fe2150c3c46 --- /dev/null +++ b/packages/mosaic/src/localization/localization.test-d.ts @@ -0,0 +1,83 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import type { MosaicCatalog, MosaicLocalization } from './catalog'; +import { useMessages } from './context'; +import type { MosaicMessages } from './registry'; + +// Message overrides are derived from the message files: nested objects or dot paths, every key +// optional, string leaves stay strings, plural leaves accept any CLDR category. + +describe('MosaicCatalog', () => { + test('accepts nested overrides', () => { + const value: MosaicCatalog = { + userButton: { trigger: { open: 'Menü für {name}' }, workspaces: { members: { few: '{count} Mitglieder' } } }, + }; + void value; + }); + + test('accepts dot paths, mixed with nested', () => { + const value: MosaicCatalog = { + 'userButton.popup.label': 'Konto', + 'userButton.workspaces.members': { many: '{count} членов' }, + userProfile: { label: 'Profil' }, + }; + void value; + }); + + test('rejects unknown keys and paths', () => { + const value: MosaicCatalog = { + // @ts-expect-error typo in the path + 'userButton.popup.lable': 'x', + }; + const nested: MosaicCatalog = { + userButton: { + // @ts-expect-error unknown key + nope: 'x', + }, + }; + void [value, nested]; + }); + + test('keeps a leaf its kind', () => { + const value: MosaicCatalog = { + // @ts-expect-error a string leaf cannot become plural forms + 'userButton.popup.label': { other: 'x' }, + }; + const nested: MosaicCatalog = { + // @ts-expect-error plural forms cannot become a string + userButton: { workspaces: { members: 'x' } }, + }; + void [value, nested]; + }); +}); + +describe('MosaicLocalization', () => { + test('takes a locale, its catalog and overrides on top', () => { + const esES: MosaicCatalog = { 'userButton.popup.label': 'Cuenta' }; + const catalog: MosaicLocalization = { locale: 'es-ES', messages: esES }; + const tweaked: MosaicLocalization = { + locale: 'es-ES', + messages: esES, + overrides: { userButton: { popup: { label: 'Mi cuenta' } } }, + }; + const english: MosaicLocalization = { overrides: { 'userButton.popup.label': 'My account' } }; + void [catalog, tweaked, english]; + }); + + test('checks the catalog and the overrides against the message keys', () => { + const value: MosaicLocalization = { + // @ts-expect-error unknown path + overrides: { 'userButton.popup.lable': 'x' }, + }; + void value; + }); +}); + +describe('useMessages', () => { + test('returns the namespace with its template types', () => { + expectTypeOf(useMessages('userButton')).toEqualTypeOf(); + expectTypeOf(useMessages('userButton').trigger.open).toEqualTypeOf<'Open account menu for {name}'>(); + // @ts-expect-error unknown namespace + useMessages('nope'); + }); +}); diff --git a/packages/mosaic/src/localization/messages.test-d.ts b/packages/mosaic/src/localization/messages.test-d.ts new file mode 100644 index 00000000000..10509fcecd1 --- /dev/null +++ b/packages/mosaic/src/localization/messages.test-d.ts @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; +import { describe, test } from 'vitest'; + +import { fill, plural, rich } from './messages'; + +// Message files are `as const`, so a template's placeholders are known at the type level and the +// helpers require exactly those params and tags. A plain `string` template keeps the loose shape. + +const m = { + open: 'Open account menu for {name}', + label: 'Account', + members: { one: '{count} member', other: '{count} members' }, + seats: { one: '{count} seat in {org}', other: '{count} seats in {org}' }, + invites: { one: '{count} invite from {sender}', other: '{count} invites' }, + removal: '{#strong}{emailAddress}{/strong} will be removed.', + countdown: 'Resend ({seconds})', + divider: 'before {#rule/} after', +} as const; + +const strong = (children?: ReactNode) => children; + +describe('fill', () => { + test('requires every placeholder and rejects unknown ones', () => { + fill(m.open, { name: 'Sam' }); + // @ts-expect-error name is required + fill(m.open, {}); + // @ts-expect-error nope is not a placeholder + fill(m.open, { name: 'Sam', nope: 1 }); + fill(m.label, {}); + // @ts-expect-error label has no placeholders + fill(m.label, { name: 'Sam' }); + }); + + test('keeps the loose shape for a plain string', () => { + const template: string = m.open; + fill(template, {}); + fill(template, { anything: 1 }); + }); +}); + +describe('plural', () => { + test('supplies count and requires the remaining placeholders', () => { + plural(m.members, 2); + plural(m.members, 2, 'de'); + plural(m.seats, 2, 'en', { org: 'Clerk' }); + // @ts-expect-error seats needs org + plural(m.seats, 2); + // @ts-expect-error members has nothing beyond count + plural(m.members, 2, 'en', { org: 'Clerk' }); + plural(m.invites, 1, 'en', { sender: 'Sam' }); + // @ts-expect-error a placeholder named by any form is required + plural(m.invites, 2); + }); +}); + +describe('rich', () => { + test('requires the values and components the template names', () => { + rich(m.removal, { values: { emailAddress: 'a@b.c' }, components: { strong } }); + // @ts-expect-error strong is required + rich(m.removal, { values: { emailAddress: 'a@b.c' } }); + // @ts-expect-error emailAddress is required + rich(m.removal, { components: { strong } }); + rich(m.countdown, { values: { seconds: 9 } }); + rich(m.divider, { components: { rule: () => null } }); + rich(m.label); + // @ts-expect-error label takes no options + rich(m.label, { values: { x: 1 } }); + }); +}); diff --git a/packages/mosaic/src/utils/messages.test.tsx b/packages/mosaic/src/localization/messages.test.tsx similarity index 100% rename from packages/mosaic/src/utils/messages.test.tsx rename to packages/mosaic/src/localization/messages.test.tsx diff --git a/packages/mosaic/src/utils/messages.ts b/packages/mosaic/src/localization/messages.ts similarity index 61% rename from packages/mosaic/src/utils/messages.ts rename to packages/mosaic/src/localization/messages.ts index 35e6fa9d913..1fd11041345 100644 --- a/packages/mosaic/src/utils/messages.ts +++ b/packages/mosaic/src/localization/messages.ts @@ -12,17 +12,60 @@ export interface RichOptions { components?: MessageComponents; } -function own(record: Record | undefined, key: string): T | undefined { +export type MessageParams = T extends `${string}{${infer Key}}${infer Rest}` + ? Key extends `#${string}` | `/${string}` + ? MessageParams + : Key | MessageParams + : never; + +export type MessageTags = T extends `${string}{#${infer Key}}${infer Rest}` + ? (Key extends `${infer Name}/` ? Name : Key) | MessageTags + : never; + +type Exactly = [K] extends [never] ? Partial> : Record; + +type Values = string extends T ? Record : Exactly, V>; + +type Components = string extends T + ? MessageComponents + : Exactly, MessageComponents[string]>; + +type TypedRichOptions = string extends T + ? [options?: RichOptions] + : [MessageParams] extends [never] + ? [MessageTags] extends [never] + ? [] + : [options: { components: Components }] + : [MessageTags] extends [never] + ? [options: { values: Values }] + : [options: { values: Values; components: Components }]; + +type PluralParams = Exclude>, 'count'>; + +type PluralValues = string extends F['other'] + ? [values?: MessageValues] + : [PluralParams] extends [never] + ? [] + : [values: Record, string | number>]; + +function own(record: Partial> | undefined, key: string): T | undefined { return record && Object.hasOwn(record, key) ? record[key] : undefined; } -export function fill(template: string, values: MessageValues): string { +export function fill(template: T, values: Values): string; +export function fill(template: string, values: Partial): string { return template.replace(/\{(\w+)\}/g, (match, key: string) => String(own(values, key) ?? match)); } -export function plural(forms: PluralForms, count: number, locale = 'en'): string { +export function plural( + forms: F, + count: number, + locale = 'en', + ...rest: PluralValues +): string { const category = new Intl.PluralRules(locale).select(count); - return fill(own(forms, category) ?? forms.other, { count }); + const template: string = own(forms, category) ?? forms.other; + return fill(template, { ...rest[0], count }); } type Token = @@ -100,6 +143,7 @@ function fold( return { nodes, next: i, closed: false }; } +export function rich(template: T, ...rest: TypedRichOptions): ReactNode; export function rich(template: string, options: RichOptions = {}): ReactNode { return createElement(Fragment, null, ...fold(tokenize(template), 0, undefined, options).nodes); } diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts new file mode 100644 index 00000000000..ef918abc594 --- /dev/null +++ b/packages/mosaic/src/localization/registry.ts @@ -0,0 +1,31 @@ +import { reverificationMessages } from '../features/reverification/reverification.messages'; +import { userButtonMessages } from '../features/user-button/user-button.messages'; +import { userProfileMessages } from '../features/user-profile/user-profile.messages'; +import { userProfileAccountSectionMessages } from '../features/user-profile/user-profile-account-section/user-profile-account-section.messages'; +import { userProfileAddEmailMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-email.messages'; +import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; +import { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; +import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; +import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts.messages'; +import { userProfileDeleteSectionMessages } from '../features/user-profile/user-profile-delete-section/user-profile-delete-section.messages'; +import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages'; +import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; +import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; + +export const mosaicMessages = { + reverification: reverificationMessages, + userButton: userButtonMessages, + userProfile: userProfileMessages, + userProfileAccountSection: userProfileAccountSectionMessages, + userProfileAddEmail: userProfileAddEmailMessages, + userProfileAddPhone: userProfileAddPhoneMessages, + userProfileVerifyEmailLink: userProfileVerifyEmailLinkMessages, + userProfileVerifyEmailSso: userProfileVerifyEmailSsoMessages, + userProfileConnectedAccounts: userProfileConnectedAccountsMessages, + userProfileDeleteSection: userProfileDeleteSectionMessages, + userProfileEnterpriseAccountsSection: userProfileEnterpriseAccountsMessages, + userProfilePasswordSection: userProfilePasswordSectionMessages, + userProfileWeb3Wallets: userProfileWeb3WalletsMessages, +}; + +export type MosaicMessages = typeof mosaicMessages; diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index a90634273e6..402ebcd5b59 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -56,7 +56,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places an entry in one of these layers. Group order follows first appearance in the `registry` array. The sidebar sorts `Blocks`, `Components`, `Primitives`, `Styles`, and `Hooks` alphabetically by `title`; `User Button`, `User Profile`, and `Reverification` render in registry order. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings: +`meta.group` places an entry in one of these layers. Group order follows first appearance in the `registry` array. The sidebar sorts `Blocks`, `Components`, `Primitives`, `Styles`, and `Hooks` alphabetically by `title`; `User Button`, `User Profile`, `Reverification`, and `Localization` render in registry order. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | @@ -66,6 +66,7 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | | `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | | `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | +| `Localization` | The `localization` prop on `MosaicProvider`: catalogs, overrides, locale, and the message helpers | B (adapted) | `User Button` / `User Profile` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index e4906a3e6aa..0151a0095ad 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -100,6 +100,9 @@ const docModules: Record> = { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), }, + localization: { + localization: dynamic(() => import('../stories/localization.mdx')), + }, }; interface DocsViewerProps { diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 995bb4eae70..6f59f18a3c9 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -26,7 +26,7 @@ import type { StoryStatus, WipSubstatus } from '@/lib/types'; const groups = getSidebarGroups(); -const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks']); +const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks', 'Localization']); type SidebarEntry = ReturnType[number]['components'][number]; @@ -126,12 +126,12 @@ function SidebarEntryMenu({ const href = `/${groupSlug}/${componentSlug}`; // How an entry is USED differs by layer, so the label follows the layer rather // than a guess at the title: hooks are called, atomic styles are a set of - // exports with no single call form worth privileging, and everything else is a - // component rendered as JSX. + // exports with no single call form worth privileging, localization is a prop rather + // than a component, and everything else is a component rendered as JSX. const usage = mod.meta.group === 'Hooks' ? `${mod.meta.title}()` - : mod.meta.group === 'Styles' + : mod.meta.group === 'Styles' || mod.meta.group === 'Localization' ? mod.meta.title : `<${mod.meta.title} />`; return ( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index cdd8a9bbd1f..8be68b24fd2 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -100,6 +100,15 @@ import { meta as itemMeta, Scrolling as ItemScrolling, } from '../stories/item.stories'; +import { + Catalog as LocalizationCatalog, + CatalogWithOverrides as LocalizationCatalogWithOverrides, + Fallback as LocalizationFallback, + Helpers as LocalizationHelpers, + meta as localizationMeta, + Overrides as LocalizationOverrides, + PluralRules as LocalizationPluralRules, +} from '../stories/localization.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { @@ -508,6 +517,16 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const localizationModule: StoryModule = { + meta: localizationMeta, + Overrides: LocalizationOverrides, + Catalog: LocalizationCatalog, + CatalogWithOverrides: LocalizationCatalogWithOverrides, + Helpers: LocalizationHelpers, + Fallback: LocalizationFallback, + PluralRules: LocalizationPluralRules, +}; + // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const tableModule: StoryModule = { meta: tableMeta }; const toastModule: StoryModule = { meta: toastMeta }; @@ -730,6 +749,8 @@ export const registry: StoryModule[] = [ scrollAreaModule, // Hooks useDataTableModule, + // Localization + localizationModule, ]; /** diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx new file mode 100644 index 00000000000..f27d877579d --- /dev/null +++ b/packages/swingset/src/stories/localization.mdx @@ -0,0 +1,248 @@ +import * as LocalizationStories from './localization.stories'; + +# Localization + +Every string Mosaic renders comes from a message file next to the feature that uses it, and +`MosaicProvider` takes a `localization` prop that replaces any of them. There are three parts: +a `locale` that picks plural forms, a `messages` catalog for that locale, and sparse `overrides` +for an app's own wording. Anything not supplied falls back to the built-in English, so a catalog +can be partial and an override can be a single key. The whole surface is typed from the message +files themselves, so an unknown key or a missing placeholder is a compile error rather than a +missing string at runtime. + +Without a provider, or with a provider and no `localization`, everything renders in English. + +## Example + +The `UserButton` under a Spanish catalog for its namespace. Open it: the header, the member +counts, the footer rows and the accessible names all come from the catalog. + + + +## Usage + +Change a few English strings: + +```tsx +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; + + + +; +``` + +Ship a language: + +```tsx +import esES from './es-ES.json'; + + + +; +``` + +Ship a language and adjust it for this app: + +```tsx + + +; +``` + +The resolved strings are memoized on the `localization` object, so hoist it out of render or +memoize it rather than writing a fresh literal on every render. + +## Catalogs + +A catalog is a `MosaicCatalog`: the message tree with every key optional. It is keyed by +namespace, one per feature (`userButton`, `userProfile`, `reverification`, …), and within a +namespace the keys mirror the feature's message file. Nested objects and dot paths are +interchangeable, and can be mixed: + +```ts +import type { MosaicCatalog } from '@clerk/mosaic/localization'; + +const overrides: MosaicCatalog = { + userButton: { manage: { invite: 'Invite teammates' } }, + 'userButton.accounts.signOutAll': 'Sign out everywhere', +}; +``` + +A leaf is a string, or a set of plural forms keyed by CLDR category. Placeholders are written +as `{name}`, and inline markup as `{#tag}…{/tag}` or a standalone `{#tag/}`. A translation keeps +the placeholders of the string it replaces; a value for a placeholder the string does not have +is left unused, and a placeholder the values do not cover is rendered literally. + +```json +{ + "userButton": { + "trigger": { "open": "Abrir el menú de la cuenta de {name}" }, + "workspaces": { + "members": { "one": "{count} miembro", "other": "{count} miembros" } + } + }, + "userProfileVerifyEmailLink": { + "resendCountdown": "¿No recibiste el enlace? Reenviar ({seconds})" + } +} +``` + +Plural leaves merge per category, so a catalog that supplies only `many` keeps the other forms +from the layer beneath it. + +The type is derived from the message files with `typeof`, so it is never out of date: a key +that does not exist in Mosaic is rejected, and so is a string where a plural object is expected. +Message keys are therefore public surface, and renaming one is a breaking change for any +catalog that names it. + +## Overrides + +`messages` and `overrides` take the same `MosaicCatalog` shape and differ only in order. English +is the floor, `messages` is applied over it, and `overrides` over that. Keep the language in +`messages` and the app's wording in `overrides`, so a catalog can be swapped or updated without +touching the overrides that sit on top of it. + +A few English strings replaced, one by dot path and one nested: + + + +The Spanish catalog from the example above with one string overridden for this app: + + + +## Locale + +`locale` is a BCP 47 tag and does one thing: it is handed to `Intl.PluralRules`, which picks the +plural category for a count. It defaults to `en`, so an English-only app never sets it. A catalog +does not declare its own language, so set `locale` alongside `messages` whenever the catalog is +not English. A value that is not a well-formed tag, such as `en_US` with an underscore, is treated +as `en` rather than thrown at render time. + +English has two categories, but Russian and Polish have four and Arabic six, and the rules for +which number takes which form come from CLDR data in the browser rather than from Mosaic. A +catalog names its cases, and the locale decides which one a given count takes: + + + +| Category | Meaning | Required | +| -------- | ------------------------------------------------- | -------- | +| `zero` | Locale-specific zero form | no | +| `one` | Singular | no | +| `two` | Dual | no | +| `few` | Paucal | no | +| `many` | Locale-specific large form | no | +| `other` | The general form, used when no other case matches | yes | + +## Reading strings in a view + +Views never import a message file. Inside the component they read their namespace with +`useMessages`, which returns the resolved object for that namespace and never changes identity +while the `localization` prop is stable, and resolve each string with the helper its +placeholders call for. A module-level helper takes the messages as a parameter. + +```tsx +import { useLocale, useMessages } from '@clerk/mosaic/localization'; +import { fill, plural, rich } from '@clerk/mosaic/localization'; + +function Trigger({ name, count }: { name: string; count: number }) { + const m = useMessages('userButton'); + const locale = useLocale(); + return ( + + ); +} +``` + +| Helper | Resolves | Returns | +| ---------------------------------------- | ------------------------------------ | ----------- | +| `fill(template, values)` | `{name}` placeholders | `string` | +| `plural(forms, count, locale, values?)` | The form for `count`, then `{count}` | `string` | +| `rich(template, { values, components })` | `{name}` and `{#tag}…{/tag}` markup | `ReactNode` | + +Each helper is typed against the literal it is given: `fill` requires exactly the placeholders +the string names, `plural` requires whatever any of the forms name besides `count`, and `rich` +requires a component per tag. Passing a string that is not a literal, such as one read from a +prop, falls back to the untyped signatures. + + + +Fallback is per key. Under the Spanish catalog, which covers only `userButton`, the verify-link +description stays English: + + + +### Adding messages to Mosaic + +- Strings live in a `.messages.ts` next to the feature, exported `as const`, grouped + by the part of the UI that renders them. +- Register the namespace in `mosaicMessages` in `packages/mosaic/src/localization/registry.ts`. + That is what extends `MosaicCatalog` and `useMessages` to the new namespace. +- A count goes through `plural` with a `{ one, other }` leaf, never through string + concatenation, and always with the locale from `useLocale`. +- Markup inside a string goes through `rich` with a `{#tag}` rather than through splitting + the string around the markup. +- A controller or machine stores the cause of an error, not its copy. The hook that turns + the snapshot into props maps the cause to a message with `useMessages`, so the copy follows + the catalog. + +## Props + +`localization` on `MosaicProvider`: + +| Prop | Type | Default | Description | +| ----------- | --------------- | ------- | ------------------------------------------------------------- | +| `locale` | `string` | `'en'` | BCP 47 tag handed to `Intl.PluralRules` to pick plural forms. | +| `messages` | `MosaicCatalog` | — | The catalog for `locale`, applied over English. | +| `overrides` | `MosaicCatalog` | — | Sparse changes applied over `messages`. | + +Exports from `@clerk/mosaic/localization`: + +| Export | Description | +| -------------------- | ---------------------------------------------- | +| `MosaicLocalization` | The `localization` prop's type. | +| `MosaicCatalog` | A partial message tree, nested or by dot path. | +| `MosaicMessages` | The full message tree, as a view reads it. | +| `mosaicMessages` | The built-in English messages. | +| `useMessages(ns)` | The resolved messages for one namespace. | +| `useLocale()` | The active locale. | + +## Design notes + +- **Typed from the source.** There is no schema, no codegen and no runtime validation. The + catalog type is `typeof` the message files, and the helpers read placeholder names out of the + string literals. Mistakes surface in the editor. +- **No message-format library.** ICU MessageFormat syntax needs a parser shipped to the browser. + The part of it that matters, plural selection, is a platform API, and `plural` calls + `Intl.PluralRules` directly. Plural cases are declared as an object rather than inline in a + string, which is what makes a missing form visible in a catalog. +- **One locale at a time.** Catalogs are not keyed by locale and there is no parent-locale + fallback. An app that switches language swaps `messages` and `locale` together, the same way + it swaps the `ClerkProvider` localization resource. +- **Silent fallback.** A key a catalog leaves out renders in English, with no warning. The + types already reject a key that does not exist, and every key exists in English. diff --git a/packages/swingset/src/stories/localization.stories.tsx b/packages/swingset/src/stories/localization.stories.tsx new file mode 100644 index 00000000000..0c7f4123875 --- /dev/null +++ b/packages/swingset/src/stories/localization.stories.tsx @@ -0,0 +1,217 @@ +import { UserButtonView } from '@clerk/mosaic/features/user-button/user-button.view'; +import type { MosaicCatalog } from '@clerk/mosaic/localization'; +import { fill, plural, rich, useLocale, useMessages } from '@clerk/mosaic/localization'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './localization.stories?raw'; + +export const meta: StoryMeta = { + group: 'Localization', + title: 'Localization', + status: 'wip', + source: 'packages/mosaic/src/localization/context.tsx', +}; + +// A catalog for one namespace, the shape an `es-ES.json` would have. Every other namespace is +// left out on purpose: whatever a catalog omits stays English. +const esES: MosaicCatalog = { + userButton: { + trigger: { open: 'Abrir el menú de la cuenta de {name}' }, + popup: { label: 'Cuenta' }, + workspaces: { + personal: 'Cuenta personal', + notSelected: 'Ninguna organización seleccionada', + loading: 'Cargando organizaciones…', + members: { one: '{count} miembro', other: '{count} miembros' }, + accept: 'Aceptar', + join: 'Unirse', + requested: 'Solicitado', + pending: 'pendiente', + }, + accounts: { + actionsFor: 'Acciones para {identifier}', + switch: 'Cambiar de cuenta', + add: 'Añadir cuenta', + signOut: 'Cerrar sesión', + signOutAll: 'Cerrar sesión en todas las cuentas', + }, + manage: { + invite: 'Invitar', + account: 'Gestionar cuenta', + organization: 'Gestionar organización', + createOrganization: 'Crear organización', + }, + }, +}; + +const colin = { + sessionId: 'sess_colin', + name: 'Colin', + identifier: 'colin@clerk.dev', + imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', +}; + +const braden = { + sessionId: 'sess_braden', + name: 'Braden', + identifier: 'braden@clerk.dev', + imageUrl: 'https://avatars.githubusercontent.com/u/64913815?v=4', +}; + +const clerkApp = { + kind: 'membership', + organizationId: 'org_clerk_app', + name: 'Clerk app', + membersCount: 24, + planLabel: 'Pro plan', + imageUrl: 'https://avatars.githubusercontent.com/u/49538330?v=4', +} as const; + +const clerkCloud = { + kind: 'membership', + organizationId: 'org_clerk_cloud', + name: 'Clerk Cloud', + membersCount: 1, +} as const; + +// A static user button: every string it renders comes from the `userButton` namespace, so it +// shows a catalog or an override the moment the popup opens. +function Workspace() { + const [open, setOpen] = useState(false); + return ( + setOpen(false)} + onSwitchSession={() => setOpen(false)} + onSignOutAll={() => setOpen(false)} + onManageOrganization={() => setOpen(false)} + onInviteMembers={() => setOpen(false)} + onManageAccount={() => setOpen(false)} + onCreateOrganization={() => setOpen(false)} + onAddAccount={() => setOpen(false)} + /> + ); +} + +function Rows({ rows }: { rows: Array<[string, ReactNode]> }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +// What a view does with its messages: read the namespace, then resolve each string with the +// helper its placeholders call for. +function Strings() { + const m = useMessages('userButton'); + const link = useMessages('userProfileVerifyEmailLink'); + const locale = useLocale(); + return ( + 30 } })], + ]} + /> + ); +} + +function Counts({ counts }: { counts: number[] }) { + const m = useMessages('userButton'); + const locale = useLocale(); + return [String(count), plural(m.workspaces.members, count, locale)])} />; +} + +export function Overrides() { + return ( + + + + ); +} + +export function Catalog() { + return ( + + + + ); +} + +export function CatalogWithOverrides() { + return ( + + + + ); +} + +export function Helpers() { + return ; +} + +export function Fallback() { + return ( + + + + ); +} + +export function PluralRules() { + return ( + + + + ); +} diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 0c02222de5d..f399ea47e80 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -74,7 +74,7 @@ State styling uses real class + attribute-selector specificity — no `&&` boost ## MosaicProvider -Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for one thing: per-name icon glyph overrides. +Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for two things: per-name icon glyph overrides and localization. ```tsx import { MosaicProvider } from './MosaicProvider'; @@ -185,7 +185,7 @@ Supporting files carry the parts that would otherwise bloat those four: ```text user-button.types.ts // the data contract the model and view both agree on -user-button.messages.ts // every string the surface renders, in `@clerk/i18n` shape +user-button.messages.ts // every string the surface renders, as one `as const` object user-button.layout.ts // pure derivation (which affordance goes in which slot) user-button.utils.ts // pure helpers user-button.styles.ts // `stylex.create` atoms (see the StyleX authoring rules) @@ -194,13 +194,29 @@ user-button.styles.ts // `stylex.create` atoms (see the StyleX authoring `*.types.ts` is worth calling out: it holds the data contract so that neither the model nor the view owns it, and the two cannot drift. -`*.messages.ts` holds strings only, exported as one `Messages` object. Views -resolve them with the helpers in `src/mosaic/utils/messages.ts`, never with -`.replace` or `.split` on the string: +`*.messages.ts` holds strings only, exported as one `Messages` object +declared `as const` and registered under a namespace in `src/localization/registry.ts`. +Views read their namespace inside the component with `useMessages('userButton')`, so +the `localization` prop on `MosaicProvider` can replace any string; a module-level +helper takes the messages as a parameter rather than importing them. -- `fill(m.manage, { value })` substitutes `{name}` placeholders. -- `plural(m.members, count)` picks a form from `{ one, other, … }` with - `Intl.PluralRules` and fills `{count}`. +```tsx +{children}; + + {children} +; +``` + +`messages` is the catalog for `locale`, `overrides` the app's own changes on top, and +English sits under both. `locale` only feeds `Intl.PluralRules`. Strings are resolved +with the helpers in `src/localization/messages.ts`, never with `.replace` or `.split` +on the string: + +- `fill(m.trigger.open, { name })` substitutes `{name}` placeholders. +- `plural(m.members, count, locale)` picks a form from `{ one, other, … }` with + `Intl.PluralRules` for the `locale` from `useLocale()` and fills `{count}`. - `rich(m.description, { values, components })` renders `{#strong}…{/strong}` markup through the component authored for that tag, so the sentence stays in the messages file and the view supplies only the element. Markup is for inline @@ -208,6 +224,16 @@ resolve them with the helpers in `src/mosaic/utils/messages.ts`, never with purposes, such as a countdown span, is passed as an element in `values`, so the string only ever sees `{seconds}`. +The helpers are typed from the string itself: `fill` accepts exactly the `{name}` +placeholders the template declares, `rich` exactly the tags it marks up, and +`plural` whatever any of the forms name besides `count`. + +Message keys are public surface. `MosaicCatalog` is derived from the registry, +so a consumer's overrides type-check against the real keys, as a dot path +(`'userButton.popup.label'`) or a nested object (`{ userButton: { popup: { label } } }`). +Renaming or moving a key breaks every override that names it; treat it like renaming +a prop. + ### Composition Two shapes, chosen by whether the slice fetches its own data. @@ -463,29 +489,30 @@ The steps above cover the **styling** migration. For **flow** components — whe ## Files -| File | Purpose | -| --------------------------------------- | --------------------------------------------------------------------------- | -| `src/tokens.stylex.ts` | `--cl-*` token groups declared with `stylex.defineVars` | -| `src/props.ts` | `themeProps`, `mergeStyleProps`, `MosaicComponentProps`, `MosaicStyleProps` | -| `src/MosaicProvider.tsx` | Provider for the `icons` prop (per-name glyph overrides) | -| `src/icons/overrides.ts` | `MosaicIconOverrides` type + `useMosaicIcons()` context | -| `src/icons/registry.tsx` | Built-in glyphs and the `IconName` union | -| `src/components/` | One subdirectory per component, and nothing else | -| `src/blocks/` | View fragments that own one piece of state of their own (`destructive`) | -| `src/utils/*.styles.ts` | Atoms shared across components: `reset`, `typography`, `focus-outline` | -| `src/hooks/` | Mosaic-only hooks (`useMosaicEnvironment`, `useMosaicRouter`, …) | -| `src/styles/index.ts` | StyleX-only barrel — the entry the CSS build walks | -| `src/machine/` | State-machine runtime (`createMachine`, `createActor`, `useMachine`) | -| `src/machines/` | Standalone machines and the shared `__tests__/test-utils.ts` | -| `src//*.model.tsx` | Clerk adapter — the only file in a feature that may import Clerk | -| `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | -| `src//*.view.tsx` | Clerk-free rendering from plain props | -| `src//*.types.ts` | The data contract the model and the view both agree on | -| `src//*.messages.ts` | Every string the surface renders, shaped the way `@clerk/i18n` takes them | -| `src/utils/reset.test.tsx` | Reset specs | -| `src/__tests__/MosaicProvider.test.tsx` | Icon-override context specs | -| `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | -| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | +| File | Purpose | +| --------------------------------------- | ---------------------------------------------------------------------------------- | +| `src/tokens.stylex.ts` | `--cl-*` token groups declared with `stylex.defineVars` | +| `src/props.ts` | `themeProps`, `mergeStyleProps`, `MosaicComponentProps`, `MosaicStyleProps` | +| `src/MosaicProvider.tsx` | Provider for the `icons` and `localization` props | +| `src/icons/overrides.ts` | `MosaicIconOverrides` type + `useMosaicIcons()` context | +| `src/icons/registry.tsx` | Built-in glyphs and the `IconName` union | +| `src/localization/` | Message registry, `MosaicCatalog` types, context hooks, and `fill`/`plural`/`rich` | +| `src/components/` | One subdirectory per component, and nothing else | +| `src/blocks/` | View fragments that own one piece of state of their own (`destructive`) | +| `src/utils/*.styles.ts` | Atoms shared across components: `reset`, `typography`, `focus-outline` | +| `src/hooks/` | Mosaic-only hooks (`useMosaicEnvironment`, `useMosaicRouter`, …) | +| `src/styles/index.ts` | StyleX-only barrel — the entry the CSS build walks | +| `src/machine/` | State-machine runtime (`createMachine`, `createActor`, `useMachine`) | +| `src/machines/` | Standalone machines and the shared `__tests__/test-utils.ts` | +| `src//*.model.tsx` | Clerk adapter — the only file in a feature that may import Clerk | +| `src//*.controller.tsx` | Local state and action wrapping; holds the feature's machine | +| `src//*.view.tsx` | Clerk-free rendering from plain props | +| `src//*.types.ts` | The data contract the model and the view both agree on | +| `src//*.messages.ts` | Every string the surface renders; its keys are the `localization` paths | +| `src/utils/reset.test.tsx` | Reset specs | +| `src/__tests__/MosaicProvider.test.tsx` | Icon-override and localization context specs | +| `src/components/button/button.test.tsx` | Component-level slot/state/variant specs | +| `src/features/user-button/__tests__/` | The canonical per-layer test set to copy from | `machine/` is the runtime; `machines/` is machines written with it. The one-letter difference is easy to misread — a feature's own machine belongs in its