From 3177d83d1ef76b0dbf83f102d8213f78b9d9213d Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 00:04:52 +0530 Subject: [PATCH 1/3] fix(react): let the sign-in field labels be customised through i18n The username and password fields of the embedded sign-in form now read their label and placeholder from the i18n bundle (elements.fields..label / .placeholder) before falling back to the text sent by the identity server, so applications whose users sign in with an email address can relabel the identifier field without changing the login flow. The default label stays "Username", since the platform supports non-email usernames. --- .changeset/signin-field-labels-i18n.md | 5 +++ .../v1/options/SignInOptionFactory.test.tsx | 42 ++++++++++++++++++- .../SignIn/v1/options/UsernamePassword.tsx | 22 ++++++++-- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 .changeset/signin-field-labels-i18n.md diff --git a/.changeset/signin-field-labels-i18n.md b/.changeset/signin-field-labels-i18n.md new file mode 100644 index 000000000..4ef9bc350 --- /dev/null +++ b/.changeset/signin-field-labels-i18n.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/react': patch +--- + +The username and password fields of the embedded sign-in form now take their label and placeholder from the i18n bundle (`elements.fields..label` / `elements.fields..placeholder`) when a translation is provided, falling back to the text returned by the identity server. This lets applications whose users sign in with an email address relabel the identifier field, for example through `preferences.i18n.bundles`, without changing the login flow. diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx index bdf588188..74625cd38 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx @@ -130,15 +130,39 @@ vi.mock('../../../../../../contexts/Theme/useTheme', () => ({ }), })); +const translations: Record = { + 'elements.fields.username.label': 'Email', + 'elements.fields.username.placeholder': 'Enter your email', +}; + +vi.mock('../../../../../../contexts/Flow/useFlow', () => ({ + default: () => ({setSubtitle: vi.fn(), setTitle: vi.fn()}), +})); + vi.mock('../../../../../../hooks/useTranslation', () => ({ default: () => ({ - t: (key: string) => key, + t: (key: string) => translations[key] ?? key, currentLanguage: 'en', setLanguage: vi.fn(), availableLanguages: ['en'], }), })); +const basicAuthenticator: any = { + authenticator: 'Username & Password', + authenticatorId: 'QmFzaWNBdXRoZW50aWNhdG9yOkxPQ0FM', + idp: 'LOCAL', + metadata: { + i18nKey: 'authenticator.basic', + params: [ + {confidential: false, displayName: 'Username', order: 0, param: 'username', type: 'STRING'}, + {confidential: true, displayName: 'Password', order: 1, param: 'password', type: 'STRING'}, + ], + promptType: 'USER_PROMPT', + }, + requiredParams: ['username', 'password'], +}; + const googleAuthenticator: any = { authenticator: 'Google', authenticatorId: ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Google, @@ -181,6 +205,22 @@ describe('createSignInOptionFromAuthenticator', () => { expect(domWarnings).toEqual([]); }); + it('lets the i18n bundle override username/password labels and falls back to the server text', () => { + const {container} = render( + createSignInOptionFromAuthenticator(basicAuthenticator, {}, {}, false, vi.fn(), vi.fn(), {}), + ); + + const username = container.querySelector('input[name="username"]') as HTMLInputElement; + const password = container.querySelector('input[name="password"]') as HTMLInputElement; + + // Overridden through the bundle. + expect(container.textContent).toContain('Email'); + expect(username.getAttribute('placeholder')).toBe('Enter your email'); + // No translation for the password field: the identity server's displayName is used. + expect(container.textContent).toContain('Password'); + expect(password.getAttribute('placeholder')).toBe('elements.fields.generic.placeholder'); + }); + it('does not leak form-state props onto a social button rendered by the sign-up factory', () => { const googleSignUpButton: any = { components: [], diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx index a6e7750b3..4db2c15db 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx @@ -54,6 +54,17 @@ const UsernamePassword: FC = ({ setSubtitle(t('username.password.subheading')); }, [setTitle, setSubtitle, t]); + /** + * Field texts can be customised through the i18n bundle (e.g. label the identifier "Email" for + * organizations whose users sign in with an email address). Falls back to the text supplied by + * the identity server when no translation exists for the field. + */ + const resolveFieldText = (key: string, fallback: string): string => { + const translated: string = t(key); + + return translated && translated !== key ? translated : fallback; + }; + return ( <> {formFields.map((param: any) => ( @@ -61,12 +72,15 @@ const UsernamePassword: FC = ({ {createField({ className: inputClassName, disabled: isLoading, - label: param.displayName, + label: resolveFieldText(`elements.fields.${param.param}.label`, param.displayName), name: param.param, onChange: (value: any) => onInputChange(param.param, value), - placeholder: t(`elements.fields.generic.placeholder`, { - field: (param.displayName || param.param).toLowerCase(), - }), + placeholder: resolveFieldText( + `elements.fields.${param.param}.placeholder`, + t(`elements.fields.generic.placeholder`, { + field: (param.displayName || param.param).toLowerCase(), + }), + ), required: authenticator.requiredParams.includes(param.param), touched: touchedFields[param.param] || false, type: From 2f6a2f8a683def5dde2e0b0353124b6e7a04dce0 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 00:22:43 +0530 Subject: [PATCH 2/3] feat(react, nextjs): accept partial i18n bundles and per-component preferences on SignIn - `preferences.i18n.bundles` is now typed as `I18nBundleOverride`, so applications can supply only the keys they change (the runtime already merged partial bundles, but the types demanded a complete bundle with metadata). - The v1 `BaseSignIn` accepts `preferences` and threads it to the sign-in options, so texts can be overridden per component like the other components; the Next.js `` exposes the same prop. - Document the override in the Next.js README. Co-Authored-By: Claude Fable 5.1 --- .changeset/signin-field-labels-i18n.md | 8 +++- packages/javascript/src/index.ts | 1 + packages/javascript/src/models/config.ts | 16 +++++-- packages/nextjs/README.md | 26 +++++++++++ .../components/presentation/SignIn/SignIn.tsx | 5 +- .../auth/SignIn/v1/BaseSignIn.tsx | 7 ++- .../SignIn/v1/options/SignInOptionFactory.tsx | 2 + .../react/src/contexts/I18n/I18nProvider.tsx | 13 ++++-- packages/react/src/hooks/useTranslation.ts | 9 ++-- .../react/src/utils/bundleFromOverride.ts | 46 +++++++++++++++++++ 10 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/utils/bundleFromOverride.ts diff --git a/.changeset/signin-field-labels-i18n.md b/.changeset/signin-field-labels-i18n.md index 4ef9bc350..b95d2029a 100644 --- a/.changeset/signin-field-labels-i18n.md +++ b/.changeset/signin-field-labels-i18n.md @@ -1,5 +1,11 @@ --- +'@asgardeo/javascript': patch '@asgardeo/react': patch +'@asgardeo/nextjs': patch --- -The username and password fields of the embedded sign-in form now take their label and placeholder from the i18n bundle (`elements.fields..label` / `elements.fields..placeholder`) when a translation is provided, falling back to the text returned by the identity server. This lets applications whose users sign in with an email address relabel the identifier field, for example through `preferences.i18n.bundles`, without changing the login flow. +Let applications relabel the embedded sign-in fields through i18n. + +- The username and password fields of the embedded sign-in form now take their label and placeholder from the i18n bundle (`elements.fields..label` / `elements.fields..placeholder`) when a translation is provided, falling back to the text returned by the identity server. This lets applications whose users sign in with an email address relabel the identifier field without changing the login flow. +- `preferences.i18n.bundles` now accepts partial bundles (`I18nBundleOverride`): only the keys being changed need to be supplied, and the bundle metadata is optional. This was already the runtime behaviour but the types required a complete bundle. +- The Next.js `` component now accepts the `preferences` prop, so texts can be overridden per component as with `` and the React SDK. diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index ac913d7e7..5a574b088 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -179,6 +179,7 @@ export type { Config, Preferences, ThemePreferences, + I18nBundleOverride, I18nPreferences, I18nStorageStrategy, WithPreferences, diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 93ef170bf..c03aabe33 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -16,7 +16,7 @@ * under the License. */ -import {I18nBundle} from '@asgardeo/i18n'; +import {I18nMetadata, I18nTranslations} from '@asgardeo/i18n'; import {Platform} from './platforms'; import {TokenEndpointAuthMethod} from './token-endpoint-auth'; import {RecursivePartial} from './utility-types'; @@ -436,12 +436,22 @@ export interface ThemePreferences { */ export type I18nStorageStrategy = 'cookie' | 'localStorage' | 'none'; +/** + * A partial translation bundle supplied by the application to override built-in texts. + */ +export interface I18nBundleOverride { + metadata?: Partial; + translations: Partial | Record; +} + export interface I18nPreferences { /** - * Custom translations to override default ones. + * Custom translations to override default ones, keyed by locale code (e.g. `en-US`). + * Only the keys you want to change need to be present; everything else is taken from the + * built-in bundle for that locale. */ bundles?: { - [key: string]: I18nBundle; + [key: string]: I18nBundleOverride; }; /** * The domain to use when setting the language cookie. diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 704b75b88..334e0cf92 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -32,6 +32,32 @@ A missing entry surfaces as Google's `Error 400: redirect_uri_mismatch`. `afterSignOutUrl` (default: the app origin) is sent as the post-logout redirect URI and must be registered as well. +## Customising texts + +Every text the embedded components render can be overridden through `preferences.i18n.bundles`, either globally on +`` or per component through the `preferences` prop of `` and ``. Only the keys +you change need to be present; the rest come from the built-in bundle. For example, if your users sign in with an +email address, relabel the identifier field: + +```tsx + +``` + +The available keys are listed in the `@asgardeo/i18n` package (`I18nTranslations`). + ## Logging The SDK logs at `error` level by default. Set `ASGARDEO_LOG_LEVEL` to `warn`, `info` or `debug` to see more, diff --git a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx index 94789c8a4..cb4747b03 100644 --- a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx +++ b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx @@ -33,7 +33,10 @@ import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; * Props for the SignIn component. * Extends BaseSignInProps for full compatibility with the React BaseSignIn component */ -export type SignInProps = Pick; +export type SignInProps = Pick< + BaseSignInProps, + 'className' | 'onSuccess' | 'onError' | 'variant' | 'size' | 'preferences' +>; /** * A SignIn component for Next.js that provides native authentication flow. diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx index c364ff950..751b9af86 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx @@ -29,6 +29,7 @@ import { EmbeddedFlowExecuteRequestConfig, handleWebAuthnAuthentication, createPackageComponentLogger, + WithPreferences, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, FormEvent, RefObject, useEffect, useState, useCallback, useRef, ReactElement} from 'react'; @@ -63,7 +64,7 @@ const isPasskeyAuthenticator = (authenticator: EmbeddedSignInFlowAuthenticator): /** * Props for the BaseSignIn component. */ -export interface BaseSignInProps { +export interface BaseSignInProps extends WithPreferences { afterSignInUrl?: string; /** @@ -182,6 +183,7 @@ const BaseSignInContent: FC = ({ variant = 'outlined', showTitle = true, showSubtitle = true, + preferences, }: BaseSignInProps): ReactElement => { const {theme} = useTheme(); const {t} = useTranslation(); @@ -1067,6 +1069,7 @@ const BaseSignInContent: FC = ({ buttonClassName: buttonClasses, error, inputClassName: inputClasses, + preferences, }, )} @@ -1092,6 +1095,7 @@ const BaseSignInContent: FC = ({ buttonClassName: buttonClasses, error, inputClassName: inputClasses, + preferences, }, )} @@ -1222,6 +1226,7 @@ const BaseSignInContent: FC = ({ buttonClassName: buttonClasses, error, inputClassName: inputClasses, + preferences, }, )} diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx index 7f1bd84da..1a63c2322 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx @@ -20,6 +20,7 @@ import { EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorKnownIdPType, ApplicationNativeAuthenticationConstants, + Preferences, WithPreferences, } from '@asgardeo/browser'; import {ReactElement} from 'react'; @@ -249,6 +250,7 @@ export const createSignInOptionFromAuthenticator = ( buttonClassName?: string; error?: string | null; inputClassName?: string; + preferences?: Preferences; }, ): ReactElement => createSignInOption({ diff --git a/packages/react/src/contexts/I18n/I18nProvider.tsx b/packages/react/src/contexts/I18n/I18nProvider.tsx index 14147afa4..2f40de555 100644 --- a/packages/react/src/contexts/I18n/I18nProvider.tsx +++ b/packages/react/src/contexts/I18n/I18nProvider.tsx @@ -16,7 +16,13 @@ * under the License. */ -import {deepMerge, I18nPreferences, I18nStorageStrategy, createPackageComponentLogger} from '@asgardeo/browser'; +import { + deepMerge, + I18nBundleOverride, + I18nPreferences, + I18nStorageStrategy, + createPackageComponentLogger, +} from '@asgardeo/browser'; import { I18nBundle, I18nTranslations, @@ -26,6 +32,7 @@ import { } from '@asgardeo/i18n'; import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; import I18nContext, {I18nContextValue} from './I18nContext'; +import bundleFromOverride from '../../utils/bundleFromOverride'; const logger: ReturnType = createPackageComponentLogger( '@asgardeo/react', @@ -226,7 +233,7 @@ const I18nProvider: FC> = ({ // 3. User-provided bundles (from props) — highest priority, override everything if (preferences?.bundles) { - Object.entries(preferences.bundles).forEach(([key, userBundle]: [string, I18nBundle]) => { + Object.entries(preferences.bundles).forEach(([key, userBundle]: [string, I18nBundleOverride]) => { const normalizedTranslations: I18nTranslations = normalizeTranslations( userBundle.translations as unknown as Record>, ); @@ -237,7 +244,7 @@ const I18nProvider: FC> = ({ translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = {...userBundle, translations: normalizedTranslations}; + merged[key] = bundleFromOverride(key, userBundle, normalizedTranslations); } }); } diff --git a/packages/react/src/hooks/useTranslation.ts b/packages/react/src/hooks/useTranslation.ts index 826898af6..603a454b2 100644 --- a/packages/react/src/hooks/useTranslation.ts +++ b/packages/react/src/hooks/useTranslation.ts @@ -16,11 +16,12 @@ * under the License. */ -import {deepMerge, I18nPreferences, Preferences} from '@asgardeo/browser'; +import {deepMerge, I18nBundleOverride, I18nPreferences, Preferences} from '@asgardeo/browser'; import {I18nBundle, I18nTranslations, normalizeTranslations} from '@asgardeo/i18n'; import {useContext, useMemo} from 'react'; import ComponentPreferencesContext from '../contexts/I18n/ComponentPreferencesContext'; import I18nContext from '../contexts/I18n/I18nContext'; +import bundleFromOverride from '../utils/bundleFromOverride'; export interface UseTranslation { /** @@ -89,7 +90,7 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW }); // Merge component-level bundles using deepMerge for better merging - Object.entries(effectivePreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundle]) => { + Object.entries(effectivePreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundleOverride]) => { const normalizedTranslations: I18nTranslations = normalizeTranslations( componentBundle.translations as unknown as Record>, ); @@ -103,8 +104,8 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - // No global bundle for this language, use component bundle as-is - merged[key] = {...componentBundle, translations: normalizedTranslations}; + // No global bundle for this language, build one from the component bundle + merged[key] = bundleFromOverride(key, componentBundle, normalizedTranslations); } }); diff --git a/packages/react/src/utils/bundleFromOverride.ts b/packages/react/src/utils/bundleFromOverride.ts new file mode 100644 index 000000000..046cd04e5 --- /dev/null +++ b/packages/react/src/utils/bundleFromOverride.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {I18nBundleOverride} from '@asgardeo/browser'; +import {I18nBundle, I18nMetadata, I18nTranslations} from '@asgardeo/i18n'; + +/** + * Builds a complete bundle from an application-supplied partial override for a locale that has + * no built-in bundle, deriving the metadata from the locale code where it is not provided. + */ +const bundleFromOverride = ( + locale: string, + override: I18nBundleOverride, + translations: I18nTranslations, +): I18nBundle => { + const [languageCode, countryCode = '']: string[] = locale.split('-'); + + return { + metadata: { + countryCode, + direction: 'ltr', + displayName: locale, + languageCode, + localeCode: locale, + ...(override.metadata ?? {}), + } as I18nMetadata, + translations, + }; +}; + +export default bundleFromOverride; From 41e0c990fb0c493d3ec707ed6131f3639ed90c14 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 01:11:24 +0530 Subject: [PATCH 3/3] fix(react): address review comments on the i18n label override - Derive the text direction from the language code when building a bundle for a locale that has no built-in bundle, instead of always assuming "ltr". - Make the sign-in label test pass `preferences` and resolve keys from that bundle, so it fails if the option factory stops forwarding it; assert the generic placeholder fallback for the password field. - Close the AsgardeoProvider example in the Next.js README. Co-Authored-By: Claude Fable 5.1 --- packages/nextjs/README.md | 2 + .../v1/options/SignInOptionFactory.test.tsx | 37 +++++++++---- .../src/utils/bundleFromOverride.test.ts | 52 +++++++++++++++++++ .../react/src/utils/bundleFromOverride.ts | 17 ++++-- 4 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/utils/bundleFromOverride.test.ts diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 334e0cf92..58d183e04 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -54,6 +54,8 @@ email address, relabel the identifier field: }, }} > + {children} + ``` The available keys are listed in the `@asgardeo/i18n` package (`I18nTranslations`). diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx index 74625cd38..062edb4df 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx @@ -130,21 +130,36 @@ vi.mock('../../../../../../contexts/Theme/useTheme', () => ({ }), })); -const translations: Record = { - 'elements.fields.username.label': 'Email', - 'elements.fields.username.placeholder': 'Enter your email', +const signInPreferences: any = { + i18n: { + bundles: { + 'en-US': { + translations: { + 'elements.fields.username.label': 'Email', + 'elements.fields.username.placeholder': 'Enter your email', + }, + }, + }, + }, }; vi.mock('../../../../../../contexts/Flow/useFlow', () => ({ default: () => ({setSubtitle: vi.fn(), setTitle: vi.fn()}), })); +// Resolves keys from the i18n preferences handed to the hook, like the real hook does for +// component-level bundles, so the test fails if `preferences` is not forwarded. vi.mock('../../../../../../hooks/useTranslation', () => ({ - default: () => ({ - t: (key: string) => translations[key] ?? key, - currentLanguage: 'en', + default: (preferences?: any) => ({ + t: (key: string, params?: Record) => { + const override: string | undefined = preferences?.bundles?.['en-US']?.translations?.[key]; + if (override) return override; + if (key === 'elements.fields.generic.placeholder') return `Enter your ${params?.['field']}`; + return key; + }, + currentLanguage: 'en-US', setLanguage: vi.fn(), - availableLanguages: ['en'], + availableLanguages: ['en-US'], }), })); @@ -207,7 +222,9 @@ describe('createSignInOptionFromAuthenticator', () => { it('lets the i18n bundle override username/password labels and falls back to the server text', () => { const {container} = render( - createSignInOptionFromAuthenticator(basicAuthenticator, {}, {}, false, vi.fn(), vi.fn(), {}), + createSignInOptionFromAuthenticator(basicAuthenticator, {}, {}, false, vi.fn(), vi.fn(), { + preferences: signInPreferences, + }), ); const username = container.querySelector('input[name="username"]') as HTMLInputElement; @@ -216,9 +233,9 @@ describe('createSignInOptionFromAuthenticator', () => { // Overridden through the bundle. expect(container.textContent).toContain('Email'); expect(username.getAttribute('placeholder')).toBe('Enter your email'); - // No translation for the password field: the identity server's displayName is used. + // No translation for the password field: the identity server's displayName and the generic placeholder are used. expect(container.textContent).toContain('Password'); - expect(password.getAttribute('placeholder')).toBe('elements.fields.generic.placeholder'); + expect(password.getAttribute('placeholder')).toBe('Enter your password'); }); it('does not leak form-state props onto a social button rendered by the sign-up factory', () => { diff --git a/packages/react/src/utils/bundleFromOverride.test.ts b/packages/react/src/utils/bundleFromOverride.test.ts new file mode 100644 index 000000000..aeacad2d9 --- /dev/null +++ b/packages/react/src/utils/bundleFromOverride.test.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {I18nBundle} from '@asgardeo/i18n'; +import {describe, expect, it} from 'vitest'; +import bundleFromOverride, {deriveTextDirection} from './bundleFromOverride'; + +describe('bundleFromOverride', () => { + it('derives the metadata from the locale code', () => { + const bundle: I18nBundle = bundleFromOverride('fr-FR', {translations: {}}, {} as any); + + expect(bundle.metadata).toEqual({ + countryCode: 'FR', + direction: 'ltr', + displayName: 'fr-FR', + languageCode: 'fr', + localeCode: 'fr-FR', + }); + }); + + it('marks right-to-left languages as rtl', () => { + expect(bundleFromOverride('ar-AE', {translations: {}}, {} as any).metadata.direction).toBe('rtl'); + expect(deriveTextDirection('he')).toBe('rtl'); + expect(deriveTextDirection('en_US')).toBe('ltr'); + }); + + it('lets the override metadata win', () => { + const bundle: I18nBundle = bundleFromOverride( + 'ar-AE', + {metadata: {direction: 'ltr', displayName: 'Arabic'}, translations: {}}, + {} as any, + ); + + expect(bundle.metadata.direction).toBe('ltr'); + expect(bundle.metadata.displayName).toBe('Arabic'); + }); +}); diff --git a/packages/react/src/utils/bundleFromOverride.ts b/packages/react/src/utils/bundleFromOverride.ts index 046cd04e5..099ca13a7 100644 --- a/packages/react/src/utils/bundleFromOverride.ts +++ b/packages/react/src/utils/bundleFromOverride.ts @@ -17,7 +17,18 @@ */ import {I18nBundleOverride} from '@asgardeo/browser'; -import {I18nBundle, I18nMetadata, I18nTranslations} from '@asgardeo/i18n'; +import {I18nBundle, I18nMetadata, I18nTextDirection, I18nTranslations} from '@asgardeo/i18n'; + +/** + * Languages written right-to-left, by ISO 639-1 code. + */ +const RTL_LANGUAGES: Set = new Set(['ar', 'dv', 'fa', 'he', 'ks', 'ku', 'ps', 'sd', 'ug', 'ur', 'yi']); + +/** + * Derives the text direction for a locale from its language code. + */ +export const deriveTextDirection = (locale: string): I18nTextDirection => + RTL_LANGUAGES.has(locale.split(/[-_]/)[0].toLowerCase()) ? 'rtl' : 'ltr'; /** * Builds a complete bundle from an application-supplied partial override for a locale that has @@ -28,12 +39,12 @@ const bundleFromOverride = ( override: I18nBundleOverride, translations: I18nTranslations, ): I18nBundle => { - const [languageCode, countryCode = '']: string[] = locale.split('-'); + const [languageCode, countryCode = '']: string[] = locale.split(/[-_]/); return { metadata: { countryCode, - direction: 'ltr', + direction: deriveTextDirection(locale), displayName: locale, languageCode, localeCode: locale,