diff --git a/.changeset/young-tires-turn.md b/.changeset/young-tires-turn.md new file mode 100644 index 000000000..1c43a6f57 --- /dev/null +++ b/.changeset/young-tires-turn.md @@ -0,0 +1,7 @@ +--- +'@asgardeo/javascript': patch +'@asgardeo/react': patch +'@asgardeo/i18n': patch +--- + +Introduce `` & improve i18n support diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 1a18e9635..fc026fbe6 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -24,3 +24,5 @@ export * from './translations'; // Utils export {default as getDefaultI18nBundles} from './utils/getDefaultI18nBundles'; +export {default as normalizeTranslations} from './utils/normalizeTranslations'; +export {default as TranslationBundleConstants} from './constants/TranslationBundleConstants'; diff --git a/packages/i18n/src/utils/normalizeTranslations.ts b/packages/i18n/src/utils/normalizeTranslations.ts new file mode 100644 index 000000000..5fa327a15 --- /dev/null +++ b/packages/i18n/src/utils/normalizeTranslations.ts @@ -0,0 +1,68 @@ +/** + * 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 {I18nTranslations} from '../models/i18n'; + +/** + * Accepts translations in either flat or namespaced format and normalizes them + * to the flat format required by the SDK. + * + * Flat format (already correct): + * ```ts + * { "signin.heading": "Sign In" } + * ``` + * + * Namespaced format (auto-converted): + * ```ts + * { signin: { heading: "Sign In" } } + * ``` + * + * Both formats can be mixed within the same object — a top-level string value + * is kept as-is, while a top-level object value is flattened one level deep + * using `"namespace.key"` concatenation. + * + * @param translations - Translations in flat or namespaced format. + * @returns Normalized flat translations compatible with `I18nTranslations`. + */ +const normalizeTranslations = ( + translations: Record> | null | undefined, +): I18nTranslations => { + if (!translations || typeof translations !== 'object') { + return {} as unknown as I18nTranslations; + } + + const result: Record = {}; + + Object.entries(translations).forEach(([topKey, value]: [string, string | Record]) => { + if (typeof value === 'string') { + // Already flat — keep as-is (e.g., "signin.heading": "Sign In") + result[topKey] = value; + } else if (value !== null && typeof value === 'object') { + // Namespaced — flatten one level (e.g., signin: { heading: "Sign In" } → "signin.heading": "Sign In") + Object.entries(value).forEach(([subKey, subValue]: [string, string]) => { + if (typeof subValue === 'string') { + result[`${topKey}.${subKey}`] = subValue; + } + }); + } + }); + + return result as unknown as I18nTranslations; +}; + +export default normalizeTranslations; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 486fb2c70..61b81ed0c 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -143,6 +143,7 @@ export { Preferences, ThemePreferences, I18nPreferences, + I18nStorageStrategy, WithPreferences, SignInOptions, SignOutOptions, @@ -210,6 +211,9 @@ export {default as resolveFieldType} from './utils/resolveFieldType'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as resolveMeta} from './utils/v2/resolveMeta'; export {default as resolveVars} from './utils/v2/resolveVars'; +export {default as countryCodeToFlagEmoji} from './utils/v2/countryCodeToFlagEmoji'; +export {default as resolveLocaleDisplayName} from './utils/v2/resolveLocaleDisplayName'; +export {default as resolveLocaleEmoji} from './utils/v2/resolveLocaleEmoji'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; export {default as transformBrandingPreferenceToTheme} from './utils/transformBrandingPreferenceToTheme'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 075a13510..8a9caac64 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -265,6 +265,17 @@ export interface ThemePreferences { overrides?: RecursivePartial; } +/** + * The storage strategy to use for persisting the user's language selection. + * + * - `'cookie'` — persists in `document.cookie` as a domain cookie (default). + * Useful for cross-subdomain scenarios where the auth portal and + * the application share a root domain. + * - `'localStorage'` — persists in `window.localStorage`. + * - `'none'` — no persistence; the resolved language is held in React state only. + */ +export type I18nStorageStrategy = 'cookie' | 'localStorage' | 'none'; + export interface I18nPreferences { /** * Custom translations to override default ones. @@ -272,6 +283,14 @@ export interface I18nPreferences { bundles?: { [key: string]: I18nBundle; }; + /** + * The domain to use when setting the language cookie. + * Only applies when `storageStrategy` is `'cookie'`. + * Defaults to the root domain derived from `window.location.hostname` + * (e.g. `'app.example.com'` → `'example.com'`). + * Override this for eTLD+1 domains like `.co.uk` or custom cookie scoping. + */ + cookieDomain?: string; /** * The fallback language to use if translations are not available in the specified language. * Defaults to 'en-US'. @@ -279,9 +298,31 @@ export interface I18nPreferences { fallbackLanguage?: string; /** * The language to use for translations. - * Defaults to the browser's default language. + * When set, acts as a hard override and bypasses all other detection sources + * (URL param, stored preference, browser language). */ language?: string; + /** + * The key used when reading/writing the language to the chosen storage. + * For `localStorage` this is the key name; for `cookie` this is the cookie name. + * @default 'asgardeo-i18n-language' + */ + storageKey?: string; + /** + * The storage strategy to use for persisting the user's language selection. + * @default 'cookie' + */ + storageStrategy?: I18nStorageStrategy; + /** + * The URL query-parameter name to inspect for a language override. + * Set to `false` to disable URL-parameter detection entirely. + * When a URL param is detected its value is immediately persisted to storage. + * @default 'lang' + * @example + * // With urlParam: 'locale', the URL ?locale=fr-FR will select French. + * // With urlParam: false, URL parameters are ignored. + */ + urlParam?: string | false; } export interface Preferences { diff --git a/packages/javascript/src/utils/v2/countryCodeToFlagEmoji.ts b/packages/javascript/src/utils/v2/countryCodeToFlagEmoji.ts new file mode 100644 index 000000000..2758f81f3 --- /dev/null +++ b/packages/javascript/src/utils/v2/countryCodeToFlagEmoji.ts @@ -0,0 +1,32 @@ +/** + * 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. + */ + +/** + * Converts a two-letter ISO 3166-1 alpha-2 country code to a flag emoji using + * Unicode Regional Indicator Symbols (U+1F1E6–U+1F1FF). + * + * @param countryCode - Two-letter uppercase country code (e.g. "US", "GB") + * @returns Flag emoji string (e.g. "🇺🇸", "🇬🇧") + */ +export default function countryCodeToFlagEmoji(countryCode: string): string { + return countryCode + .toUpperCase() + .split('') + .map((char: string) => String.fromCodePoint(0x1f1e6 - 65 + char.charCodeAt(0))) + .join(''); +} diff --git a/packages/javascript/src/utils/v2/resolveLocaleDisplayName.ts b/packages/javascript/src/utils/v2/resolveLocaleDisplayName.ts new file mode 100644 index 000000000..d4b472e28 --- /dev/null +++ b/packages/javascript/src/utils/v2/resolveLocaleDisplayName.ts @@ -0,0 +1,37 @@ +/** + * 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. + */ + +/** + * Resolves a BCP 47 locale tag to a human-readable display name using the + * `Intl.DisplayNames` API. + * + * Falls back to the raw locale code if the runtime does not support + * `Intl.DisplayNames` or if resolution returns `undefined`. + * + * @param locale - BCP 47 locale tag to resolve (e.g. "en", "fr", "zh-Hant") + * @param displayLocale - Locale used for the display name language (defaults to "en") + * @returns Human-readable language name (e.g. "English", "French") + */ +export default function resolveLocaleDisplayName(locale: string, displayLocale: string): string { + try { + const displayNames: Intl.DisplayNames = new Intl.DisplayNames([displayLocale], {type: 'language'}); + return displayNames.of(locale) ?? locale; + } catch { + return locale; + } +} diff --git a/packages/javascript/src/utils/v2/resolveLocaleEmoji.ts b/packages/javascript/src/utils/v2/resolveLocaleEmoji.ts new file mode 100644 index 000000000..c43f71eac --- /dev/null +++ b/packages/javascript/src/utils/v2/resolveLocaleEmoji.ts @@ -0,0 +1,90 @@ +/** + * 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 countryCodeToFlagEmoji from './countryCodeToFlagEmoji'; + +/** + * Maps BCP 47 language subtags to ISO 3166-1 alpha-2 country codes used for + * flag emoji resolution when no country subtag is present in the locale. + */ +const LANGUAGE_TO_COUNTRY: Readonly> = { + am: 'ET', + ar: 'SA', + bn: 'BD', + cs: 'CZ', + da: 'DK', + de: 'DE', + el: 'GR', + en: 'GB', + es: 'ES', + fa: 'IR', + fi: 'FI', + fr: 'FR', + he: 'IL', + hi: 'IN', + hu: 'HU', + id: 'ID', + it: 'IT', + ja: 'JP', + ko: 'KR', + ml: 'IN', + ms: 'MY', + nl: 'NL', + no: 'NO', + pl: 'PL', + pt: 'PT', + ro: 'RO', + ru: 'RU', + si: 'LK', + sk: 'SK', + sv: 'SE', + sw: 'KE', + ta: 'IN', + th: 'TH', + tr: 'TR', + uk: 'UA', + ur: 'PK', + vi: 'VN', + zh: 'CN', +}; + +/** + * Resolves a BCP 47 locale tag to a flag emoji. + * + * Resolution order: + * 1. Country subtag when present (e.g. `"en-US"` → 🇺🇸) + * 2. Language-to-country fallback map (e.g. `"en"` → 🇬🇧) + * 3. Globe emoji 🌐 for unrecognised codes + * + * @param locale - BCP 47 locale tag (e.g. "en", "en-US", "fr-CA") + * @returns Flag or globe emoji string + */ +function resolveLocaleEmoji(locale: string): string { + const parts: string[] = locale.split('-'); + const languageCode: string = parts[0].toLowerCase(); + const countrySubtag: string | undefined = parts.length > 1 ? parts[parts.length - 1].toUpperCase() : undefined; + + const countryCode: string | undefined = countrySubtag ?? LANGUAGE_TO_COUNTRY[languageCode]; + + if (!countryCode || countryCode.length !== 2) { + return '\u{1F310}'; // 🌐 + } + + return countryCodeToFlagEmoji(countryCode); +} +export default resolveLocaleEmoji; diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 365ad2095..d64b8d0dd 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -456,6 +456,13 @@ class AsgardeoReactClient e // Tracker: https://github.com/asgardeo/javascript/issues/212#issuecomment-3435713699 if (config.platform === Platform.AsgardeoV2) { this.asgardeo.clearSession(); + + if (config.signInUrl) { + navigate(config.signInUrl); + } else { + this.signIn(config.signInOptions); + } + args[1]?.(config.afterSignOutUrl || ''); return Promise.resolve(config.afterSignOutUrl || ''); diff --git a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx index e4ddb85fd..c7f5d1d4c 100644 --- a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx +++ b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {CreateOrganizationPayload, createPackageComponentLogger} from '@asgardeo/browser'; +import {CreateOrganizationPayload, createPackageComponentLogger, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {ChangeEvent, CSSProperties, FC, FormEvent, ReactElement, ReactNode, useState} from 'react'; import useStyles from './BaseCreateOrganization.styles'; @@ -59,8 +59,15 @@ export interface BaseCreateOrganizationProps { onSubmit?: (payload: CreateOrganizationPayload) => void | Promise; onSuccess?: (organization: any) => void; open?: boolean; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; renderAdditionalFields?: () => ReactNode; style?: CSSProperties; + title?: string; } @@ -95,13 +102,14 @@ export const BaseCreateOrganization: FC = ({ onSubmit, onSuccess, open = false, + preferences, renderAdditionalFields, style, title = 'Create Organization', }: BaseCreateOrganizationProps): ReactElement => { const {theme, colorScheme} = useTheme(); const styles: ReturnType = useStyles(theme, colorScheme); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const [formData, setFormData] = useState({ description: '', handle: '', diff --git a/packages/react/src/components/presentation/LanguageSwitcher/BaseLanguageSwitcher.styles.ts b/packages/react/src/components/presentation/LanguageSwitcher/BaseLanguageSwitcher.styles.ts new file mode 100644 index 000000000..a2d8acc3e --- /dev/null +++ b/packages/react/src/components/presentation/LanguageSwitcher/BaseLanguageSwitcher.styles.ts @@ -0,0 +1,142 @@ +/** + * 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 {Theme} from '@asgardeo/browser'; +import {css} from '@emotion/css'; +import {useMemo} from 'react'; + +const useStyles = (theme: Theme, colorScheme: string): Record => + useMemo(() => { + const root: string = css` + display: inline-block; + position: relative; + font-family: ${theme.vars.typography.fontFamily}; + `; + + const trigger: string = css` + display: inline-flex; + align-items: center; + gap: calc(${theme.vars.spacing.unit} * 0.5); + padding: calc(${theme.vars.spacing.unit} * 0.75) ${theme.vars.spacing.unit}; + border: 1px solid ${theme.vars.colors.border}; + background: ${theme.vars.colors.background.surface}; + cursor: pointer; + border-radius: ${theme.vars.borderRadius.medium}; + min-width: 120px; + font-size: 0.875rem; + color: ${theme.vars.colors.text.primary}; + + &:hover { + background-color: ${theme.vars.colors.action?.hover || 'rgba(0, 0, 0, 0.04)'}; + } + `; + + const triggerEmoji: string = css` + font-size: 1rem; + line-height: 1; + `; + + const triggerLabel: string = css` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + `; + + const content: string = css` + min-width: 200px; + max-width: 320px; + background-color: ${theme.vars.colors.background.surface}; + border-radius: ${theme.vars.borderRadius.medium}; + box-shadow: ${theme.vars.shadows.medium}; + border: 1px solid ${theme.vars.colors.border}; + outline: none; + z-index: 1000; + padding: calc(${theme.vars.spacing.unit} * 0.5) 0; + `; + + const option: string = css` + display: flex; + align-items: center; + gap: ${theme.vars.spacing.unit}; + padding: calc(${theme.vars.spacing.unit} * 1) calc(${theme.vars.spacing.unit} * 1.5); + width: 100%; + border: none; + background-color: transparent; + cursor: pointer; + font-size: 0.875rem; + text-align: start; + color: ${theme.vars.colors.text.primary}; + transition: background-color 0.15s ease-in-out; + + &:hover { + background-color: ${theme.vars.colors.action?.hover || 'rgba(0, 0, 0, 0.04)'}; + } + `; + + const optionActive: string = css` + font-weight: 600; + color: ${theme.vars.colors.primary?.main || theme.vars.colors.text.primary}; + `; + + const optionEmoji: string = css` + font-size: 1rem; + line-height: 1; + flex-shrink: 0; + `; + + const optionLabel: string = css` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + const checkIcon: string = css` + color: ${theme.vars.colors.primary?.main || theme.vars.colors.text.primary}; + flex-shrink: 0; + margin-inline-start: auto; + `; + + return { + checkIcon, + content, + option, + optionActive, + optionEmoji, + optionLabel, + root, + trigger, + triggerEmoji, + triggerLabel, + }; + }, [ + theme.vars.colors.background.surface, + theme.vars.colors.text.primary, + theme.vars.colors.border, + theme.vars.borderRadius.medium, + theme.vars.shadows.medium, + theme.vars.spacing.unit, + theme.vars.colors.action?.hover, + theme.vars.typography.fontFamily, + theme.vars.colors.primary?.main, + colorScheme, + ]); + +export default useStyles; diff --git a/packages/react/src/components/presentation/LanguageSwitcher/BaseLanguageSwitcher.tsx b/packages/react/src/components/presentation/LanguageSwitcher/BaseLanguageSwitcher.tsx new file mode 100644 index 000000000..7c0da8f71 --- /dev/null +++ b/packages/react/src/components/presentation/LanguageSwitcher/BaseLanguageSwitcher.tsx @@ -0,0 +1,195 @@ +/** + * 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 {cx} from '@emotion/css'; +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react'; +import {FC, ReactElement, ReactNode, useState} from 'react'; +import useStyles from './BaseLanguageSwitcher.styles'; +import useTheme from '../../../contexts/Theme/useTheme'; +import Check from '../../primitives/Icons/Check'; +import ChevronDown from '../../primitives/Icons/ChevronDown'; + +/** + * A resolved language option with display name and emoji flag. + */ +export interface LanguageOption { + /** BCP 47 language tag (e.g. "en", "fr", "en-US") */ + code: string; + /** Human-readable display name resolved via Intl.DisplayNames */ + displayName: string; + /** Flag emoji or globe emoji for the language */ + emoji: string; +} + +/** + * Render props exposed to consumers when using the render-prop pattern. + */ +export interface LanguageSwitcherRenderProps { + /** The currently active language code */ + currentLanguage: string; + /** Whether a language switch is in progress */ + isLoading: boolean; + /** Resolved language options */ + languages: LanguageOption[]; + /** Call this to switch to a different language */ + onLanguageChange: (language: string) => void; +} + +export interface BaseLanguageSwitcherProps { + /** + * Render-props callback. When provided, the default dropdown UI is replaced with + * whatever JSX the callback returns. + * + * @example + * ```tsx + * + * {({languages, currentLanguage, onLanguageChange}) => ( + * + * )} + * + * ``` + */ + children?: (props: LanguageSwitcherRenderProps) => ReactNode; + /** Additional CSS class applied to the root element (default UI only) */ + className?: string; + /** The currently active language code */ + currentLanguage: string; + /** Whether a language switch is in progress */ + isLoading?: boolean; + /** Resolved language options to display */ + languages: LanguageOption[]; + /** Called when the user selects a language */ + onLanguageChange: (language: string) => void; +} + +/** + * Pure-UI language switcher dropdown. + * Accepts resolved `LanguageOption[]` (code + displayName + emoji) and delegates + * language switching to the `onLanguageChange` callback. + * + * Supports render props for full UI customisation. + */ +const BaseLanguageSwitcher: FC = ({ + children, + className, + currentLanguage, + isLoading = false, + languages, + onLanguageChange, +}: BaseLanguageSwitcherProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: Record = useStyles(theme, colorScheme); + const [isOpen, setIsOpen] = useState(false); + + const {refs, floatingStyles, context} = useFloating({ + middleware: [offset(4), flip(), shift()], + onOpenChange: setIsOpen, + open: isOpen, + whileElementsMounted: autoUpdate, + }); + + const click: ReturnType = useClick(context); + const dismiss: ReturnType = useDismiss(context); + const role: ReturnType = useRole(context, {role: 'listbox'}); + const {getReferenceProps, getFloatingProps} = useInteractions([click, dismiss, role]); + + const currentOption: LanguageOption | undefined = languages.find((l: LanguageOption) => l.code === currentLanguage); + + if (children) { + return ( + <> + {children({ + currentLanguage, + isLoading, + languages, + onLanguageChange, + })} + + ); + } + + return ( +
+ + + {isOpen && ( + + +
+ {languages.map((lang: LanguageOption) => ( + + ))} +
+
+
+ )} +
+ ); +}; + +export default BaseLanguageSwitcher; diff --git a/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx b/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx new file mode 100644 index 000000000..512c4a09e --- /dev/null +++ b/packages/react/src/components/presentation/LanguageSwitcher/LanguageSwitcher.tsx @@ -0,0 +1,118 @@ +/** + * 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 {resolveLocaleDisplayName, resolveLocaleEmoji} from '@asgardeo/browser'; +import {FC, ReactElement, ReactNode, useMemo} from 'react'; +import BaseLanguageSwitcher, {LanguageOption, LanguageSwitcherRenderProps} from './BaseLanguageSwitcher'; +import useFlowMeta from '../../../contexts/FlowMeta/useFlowMeta'; +import useTranslation from '../../../hooks/useTranslation'; + +export type {LanguageOption, LanguageSwitcherRenderProps}; + +export interface LanguageSwitcherProps { + /** + * Render-props callback for fully custom UI. + * + * @example + * ```tsx + * + * {({languages, currentLanguage, onLanguageChange, isLoading}) => ( + * + * )} + * + * ``` + */ + children?: (props: LanguageSwitcherRenderProps) => ReactNode; + /** Additional CSS class for the root element (default UI only) */ + className?: string; +} + +/** + * A v2 LanguageSwitcher component that reads available languages from `FlowMetaContext` + * and switches both the UI language (via `I18nContext`) and the flow metadata translations + * (by re-fetching `GET /flow/meta` with the new language). + * + * Must be rendered inside a `FlowMetaProvider`. + * + * @example + * ```tsx + * // Default dropdown UI + * + * + * // Custom UI with render props + * + * {({languages, currentLanguage, onLanguageChange}) => ( + *
+ * {languages.map(lang => ( + * + * ))} + *
+ * )} + *
+ * ``` + */ +const LanguageSwitcher: FC = ({children, className}: LanguageSwitcherProps): ReactElement => { + const {meta, switchLanguage, isLoading} = useFlowMeta(); + const {currentLanguage} = useTranslation(); + + const availableLanguageCodes: string[] = meta?.i18n?.languages ?? []; + + const languages: LanguageOption[] = useMemo( + () => + availableLanguageCodes.map((code: string) => ({ + code, + displayName: resolveLocaleDisplayName(code, currentLanguage), + emoji: resolveLocaleEmoji(code), + })), + [availableLanguageCodes, currentLanguage], + ); + + const handleLanguageChange = (language: string): void => { + if (language !== currentLanguage) { + switchLanguage(language); + } + }; + + return ( + + {children} + + ); +}; + +export default LanguageSwitcher; diff --git a/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx b/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx index 9068597a6..94399004b 100644 --- a/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx +++ b/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {AllOrganizationsApiResponse, Organization} from '@asgardeo/browser'; +import {AllOrganizationsApiResponse, Organization, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {CSSProperties, FC, MouseEvent, ReactElement, ReactNode, useMemo} from 'react'; import useStyles from './BaseOrganizationList.styles'; @@ -88,6 +88,12 @@ export interface BaseOrganizationListProps { * Whether the popup is open (only used in popup mode) */ open?: boolean; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; /** * Custom renderer for when no organizations are found */ @@ -116,6 +122,7 @@ export interface BaseOrganizationListProps { * Inline styles to apply to the container */ style?: CSSProperties; + /** * Title for the popup dialog (only used in popup mode) */ @@ -269,10 +276,11 @@ export const BaseOrganizationList: FC = ({ style, title = 'Organizations', showStatus, + preferences, }: BaseOrganizationListProps): ReactElement => { const {theme, colorScheme} = useTheme(); const styles: ReturnType = useStyles(theme, colorScheme); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const organizationsWithSwitchAccess: OrganizationWithSwitchAccess[] = useMemo(() => { if (!allOrganizations?.organizations) { diff --git a/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx index ff67e3d3b..145322702 100644 --- a/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx +++ b/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {OrganizationDetails, formatDate} from '@asgardeo/browser'; +import {OrganizationDetails, formatDate, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useState, useCallback} from 'react'; import useStyles from './BaseOrganizationProfile.styles'; @@ -99,6 +99,13 @@ export interface BaseOrganizationProfileProps { */ organization?: OrganizationDetails | null; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Text for the save button (only used in editable mode). */ diff --git a/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx index ae7348070..d74a9e331 100644 --- a/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx +++ b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx @@ -144,10 +144,11 @@ const OrganizationProfile: FC = ({ popupTitle, loadingFallback, errorFallback, + preferences, ...rest }: OrganizationProfileProps): ReactElement => { const {baseUrl, instanceId} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const [organization, setOrganization] = useState(null); const fetchOrganization = async (): Promise => { @@ -206,6 +207,7 @@ const OrganizationProfile: FC = ({ open={open} onOpenChange={onOpenChange} title={popupTitle || t('organization.profile.heading')} + preferences={preferences} {...rest} /> ); diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx index 167deb7c3..d55ed0d73 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx @@ -17,6 +17,7 @@ */ // Removed BEM and vendor prefix utilities +import {Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import { useFloating, @@ -131,6 +132,12 @@ export interface BaseOrganizationSwitcherProps { * The HTML element ID where the portal should be mounted */ portalId?: string; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; /** * Custom render function for the error state. */ @@ -155,6 +162,7 @@ export interface BaseOrganizationSwitcherProps { * Show organization name next to avatar in the trigger button */ showTriggerLabel?: boolean; + /** * Custom styles for the component. */ @@ -185,12 +193,13 @@ export const BaseOrganizationSwitcher: FC = ({ showTriggerLabel = true, avatarSize = 24, fallback = null, + preferences, }: BaseOrganizationSwitcherProps): ReactElement => { const {theme, colorScheme, direction} = useTheme(); const styles: Record = useStyles(theme, colorScheme); const [isOpen, setIsOpen] = useState(false); const [hoveredItemIndex, setHoveredItemIndex] = useState(null); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const isRTL: boolean = direction === 'rtl'; const {refs, floatingStyles, context} = useFloating({ diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx index 0f164df5c..837aad2e9 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -87,6 +87,7 @@ export const OrganizationSwitcher: FC = ({ fallback = null, onOrganizationSwitch: propOnOrganizationSwitch, organizations: propOrganizations, + preferences, ...props }: OrganizationSwitcherProps): ReactElement => { const {isSignedIn} = useAsgardeo(); @@ -100,7 +101,7 @@ export const OrganizationSwitcher: FC = ({ const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false); const [isOrganizationListOpen, setIsOrganizationListOpen] = useState(false); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); if (!isSignedIn && fallback) { return fallback; @@ -155,6 +156,7 @@ export const OrganizationSwitcher: FC = ({ error={error} menuItems={menuItems} onManageProfile={handleManageOrganization} + preferences={preferences} {...props} /> void; onUpdate?: (payload: any) => Promise; open?: boolean; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; profile?: User; schemas?: Schema[]; showFields?: string[]; + title?: string; } @@ -125,6 +132,7 @@ const BaseUserProfile: FC = ({ open = false, error = null, isLoading = false, + preferences, showFields = [], hideFields = [], displayNameAttributes = [], @@ -132,7 +140,7 @@ const BaseUserProfile: FC = ({ const {theme, colorScheme} = useTheme(); const [editedUser, setEditedUser] = useState(flattenedProfile || profile); const [editingFields, setEditingFields] = useState>({}); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); /** * Determines if a field should be visible based on showFields, hideFields, and fieldsToSkip arrays. diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index 115d49700..b4b8bf3c1 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -64,10 +64,10 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({...rest}: UserProfileProps): ReactElement => { +const UserProfile: FC = ({preferences, ...rest}: UserProfileProps): ReactElement => { const {baseUrl, instanceId} = useAsgardeo(); const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const [error, setError] = useState(null); @@ -95,6 +95,7 @@ const UserProfile: FC = ({...rest}: UserProfileProps): ReactEl schemas={schemas} onUpdate={handleProfileUpdate} error={error} + preferences={preferences} {...rest} /> ); diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx index 8e05b1055..a7908b848 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {FlowMetadataResponse} from '@asgardeo/browser'; +import {FlowMetadataResponse, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import useStyles from './BaseAcceptInvite.styles'; @@ -208,6 +208,13 @@ export interface BaseAcceptInviteProps { */ onSubmit: (payload: Record) => Promise; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Whether to show the subtitle. */ @@ -254,13 +261,14 @@ const BaseAcceptInvite: FC = ({ onGoToSignIn, className = '', children, + preferences, size = 'medium', variant = 'outlined', showTitle = true, showSubtitle = true, }: BaseAcceptInviteProps): ReactElement => { const {meta} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const {theme} = useTheme(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx index 2492ddd1b..cf0511f8c 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {EmbeddedFlowType, FlowMetadataResponse} from '@asgardeo/browser'; +import {EmbeddedFlowType, FlowMetadataResponse, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import useStyles from './BaseInviteUser.styles'; @@ -196,6 +196,13 @@ export interface BaseInviteUserProps { */ onSubmit: (payload: Record) => Promise; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Whether to show the subtitle. */ @@ -241,13 +248,14 @@ const BaseInviteUser: FC = ({ className = '', children, isInitialized = true, + preferences, size = 'medium', variant = 'outlined', showTitle = true, showSubtitle = true, }: BaseInviteUserProps): ReactElement => { const {meta} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const {theme} = useTheme(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index b65c97287..80941c08e 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -21,6 +21,7 @@ import { EmbeddedSignInFlowHandleResponse, EmbeddedSignInFlowHandleRequestPayload, Platform, + Preferences, } from '@asgardeo/browser'; import {FC, ReactElement} from 'react'; import BaseSignIn, {BaseSignInProps} from './BaseSignIn'; @@ -36,6 +37,10 @@ export type SignInProps = Pick ReactElement; + /** + * Component-level preferences to override global i18n and theme settings. + */ + preferences?: Preferences; }; /** @@ -63,7 +68,7 @@ export type SignInProps = Pick = ({className, size = 'medium', children, ...rest}: SignInProps) => { +const SignIn: FC = ({className, size = 'medium', children, preferences, ...rest}: SignInProps) => { const {signIn, afterSignInUrl, isInitialized, isLoading, platform} = useAsgardeo(); /** @@ -105,6 +110,7 @@ const SignIn: FC = ({className, size = 'medium', children, ...rest} variant={rest.variant} onSuccess={rest.onSuccess} onError={rest.onError} + preferences={preferences} > {children} @@ -123,6 +129,7 @@ const SignIn: FC = ({className, size = 'medium', children, ...rest} showLogo={true} showSubtitle={true} showTitle={true} + preferences={preferences} {...rest} /> ); diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx index b47078c28..3ea233dda 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx @@ -21,6 +21,7 @@ import { EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, EmbeddedFlowComponentV2 as EmbeddedFlowComponent, FlowMetadataResponse, + Preferences, resolveVars, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; @@ -28,6 +29,7 @@ import {FC, useState, useCallback, ReactElement, ReactNode} from 'react'; import useAsgardeo from '../../../../../contexts/Asgardeo/useAsgardeo'; import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; import useFlow from '../../../../../contexts/Flow/useFlow'; +import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; import useTheme from '../../../../../contexts/Theme/useTheme'; import {FormField, useForm} from '../../../../../hooks/useForm'; import useTranslation from '../../../../../hooks/useTranslation'; @@ -185,6 +187,13 @@ export interface BaseSignInProps { */ onSuccess?: (authData: Record) => void; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Whether to show the logo. */ @@ -631,11 +640,11 @@ const BaseSignInContent: FC = ({ * * ``` */ -const BaseSignIn: FC = ({showLogo = true, ...rest}: BaseSignInProps): ReactElement => { +const BaseSignIn: FC = ({preferences, showLogo = true, ...rest}: BaseSignInProps): ReactElement => { const {theme} = useTheme(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); - return ( + const content: ReactElement = (
{showLogo && (
@@ -647,6 +656,10 @@ const BaseSignIn: FC = ({showLogo = true, ...rest}: BaseSignInP
); + + if (!preferences) return content; + + return {content}; }; export default BaseSignIn; diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx index 9ce123f96..f52f37d12 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx @@ -25,6 +25,7 @@ import { EmbeddedSignInFlowStatusV2, EmbeddedSignInFlowTypeV2, FlowMetadataResponse, + Preferences, } from '@asgardeo/browser'; import {FC, ReactElement, useState, useEffect, useRef, ReactNode} from 'react'; // eslint-disable-next-line import/no-named-as-default @@ -103,6 +104,13 @@ export type SignInProps = { */ onSuccess?: (authData: Record) => void; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Size variant for the component. */ @@ -189,6 +197,7 @@ interface PasskeyState { */ const SignIn: FC = ({ className, + preferences, size = 'medium', onSuccess, onError, @@ -196,7 +205,7 @@ const SignIn: FC = ({ children, }: SignInProps): ReactElement => { const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); // State management for the flow const [components, setComponents] = useState([]); @@ -687,6 +696,7 @@ const SignIn: FC = ({ className={className} size={size} variant={variant} + preferences={preferences} /> ); }; diff --git a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx index d9e08fc06..d18ca0629 100644 --- a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx @@ -24,12 +24,14 @@ import { withVendorCSSClassPrefix, EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, createPackageComponentLogger, + Preferences, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useEffect, useState, useCallback, useRef} from 'react'; import useAsgardeo from '../../../../../contexts/Asgardeo/useAsgardeo'; import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; import useFlow from '../../../../../contexts/Flow/useFlow'; +import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; import useTheme from '../../../../../contexts/Theme/useTheme'; import {useForm, FormField} from '../../../../../hooks/useForm'; import useTranslation from '../../../../../hooks/useTranslation'; @@ -208,6 +210,13 @@ export interface BaseSignUpProps { * @returns Promise resolving to the sign-up response. */ onSubmit?: (payload: EmbeddedFlowExecuteRequestPayload) => Promise; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Whether to redirect after sign-up. */ @@ -1016,11 +1025,11 @@ const BaseSignUpContent: FC = ({ * This component handles both the presentation layer and sign-up flow logic. * It accepts API functions as props to maintain framework independence. */ -const BaseSignUp: FC = ({showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { +const BaseSignUp: FC = ({preferences, showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { const {theme, colorScheme} = useTheme(); const styles: any = useStyles(theme, colorScheme); - return ( + const content: ReactElement = (
{showLogo && (
@@ -1032,6 +1041,10 @@ const BaseSignUp: FC = ({showLogo = true, ...rest}: BaseSignUpP
); + + if (!preferences) return content; + + return {content}; }; export default BaseSignUp; diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaContext.ts b/packages/react/src/contexts/FlowMeta/FlowMetaContext.ts index ab9742535..c8de880a3 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaContext.ts +++ b/packages/react/src/contexts/FlowMeta/FlowMetaContext.ts @@ -36,6 +36,11 @@ export interface FlowMetaContextValue { * The fetched flow metadata response, or null while loading / on error */ meta: FlowMetadataResponse | null; + /** + * Fetches flow metadata for the given language and activates it in the i18n system. + * Use this to switch the UI language at runtime. + */ + switchLanguage: (language: string) => Promise; } const FlowMetaContext: Context = createContext(null); diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx index 08627b643..3c7fbd092 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx +++ b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx @@ -17,7 +17,7 @@ */ import {FlowMetadataResponse, FlowMetaType, getFlowMetaV2} from '@asgardeo/browser'; -import {I18nBundle} from '@asgardeo/i18n'; +import {I18nBundle, TranslationBundleConstants} from '@asgardeo/i18n'; import { FC, PropsWithChildren, @@ -74,6 +74,7 @@ const FlowMetaProvider: FC> = ({ const [meta, setMeta] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [pendingLanguage, setPendingLanguage] = useState(null); // Track whether an initial fetch has been triggered so we don't double-fetch // when the component first mounts with a stable config reference. @@ -98,6 +99,56 @@ const FlowMetaProvider: FC> = ({ } }, [enabled, baseUrl, applicationId]); + const switchLanguage: (language: string) => Promise = useCallback( + async (language: string): Promise => { + if (!enabled) return; + + setIsLoading(true); + setError(null); + + try { + const result: FlowMetadataResponse = await getFlowMetaV2({ + baseUrl, + id: applicationId, + language, + type: FlowMetaType.App, + }); + + // Inject translations for the new language before switching + if (result.i18n?.translations && i18nContext?.injectBundles) { + const flatTranslations: Record = {}; + Object.entries(result.i18n.translations).forEach(([namespace, keys]: [string, Record]) => { + Object.entries(keys).forEach(([key, value]: [string, string]) => { + flatTranslations[`${namespace}.${key}`] = value; + }); + }); + const bundle: I18nBundle = {translations: flatTranslations} as unknown as I18nBundle; + i18nContext.injectBundles({[language]: bundle}); + } + + // Defer setLanguage to the next effect cycle so injectBundles state + // is committed before I18nProvider's setLanguage checks mergedBundles. + setPendingLanguage(language); + setMeta(result); + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsLoading(false); + } + }, + [enabled, baseUrl, applicationId, i18nContext], + ); + + // After injectBundles + setPendingLanguage are batched and committed, this + // effect fires with the updated i18nContext (mergedBundles now includes the + // new language), so setLanguage succeeds on the first switch. + useEffect(() => { + if (pendingLanguage && i18nContext?.setLanguage) { + i18nContext.setLanguage(pendingLanguage); + setPendingLanguage(null); + } + }, [pendingLanguage, i18nContext?.setLanguage]); + useEffect(() => { if (!hasFetchedRef.current) { hasFetchedRef.current = true; @@ -115,7 +166,7 @@ const FlowMetaProvider: FC> = ({ return; } - const metaLanguage: string = meta.i18n.language || 'en'; + const metaLanguage: string = meta.i18n.language || TranslationBundleConstants.FALLBACK_LOCALE; // Flatten namespace-keyed translations to dot-path keys: // { "signin": { "heading": "Sign In" } } → { "signin.heading": "Sign In" } @@ -146,6 +197,7 @@ const FlowMetaProvider: FC> = ({ fetchFlowMeta, isLoading, meta, + switchLanguage, }; return {children}; diff --git a/packages/react/src/contexts/I18n/ComponentPreferencesContext.ts b/packages/react/src/contexts/I18n/ComponentPreferencesContext.ts new file mode 100644 index 000000000..1ede3089b --- /dev/null +++ b/packages/react/src/contexts/I18n/ComponentPreferencesContext.ts @@ -0,0 +1,29 @@ +/** + * 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 {Preferences} from '@asgardeo/browser'; +import {Context, createContext} from 'react'; + +/** + * Context for component-level preferences overrides. + * Presentational components can provide this context to override the global i18n + * and theme settings for their entire subtree, including all nested components. + */ +const ComponentPreferencesContext: Context = createContext(undefined); + +export default ComponentPreferencesContext; diff --git a/packages/react/src/contexts/I18n/I18nProvider.tsx b/packages/react/src/contexts/I18n/I18nProvider.tsx index 37d3222ef..14147afa4 100644 --- a/packages/react/src/contexts/I18n/I18nProvider.tsx +++ b/packages/react/src/contexts/I18n/I18nProvider.tsx @@ -16,8 +16,14 @@ * under the License. */ -import {deepMerge, I18nPreferences, createPackageComponentLogger} from '@asgardeo/browser'; -import {I18nBundle, getDefaultI18nBundles} from '@asgardeo/i18n'; +import {deepMerge, I18nPreferences, I18nStorageStrategy, createPackageComponentLogger} from '@asgardeo/browser'; +import { + I18nBundle, + I18nTranslations, + TranslationBundleConstants, + getDefaultI18nBundles, + normalizeTranslations, +} from '@asgardeo/i18n'; import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; import I18nContext, {I18nContextValue} from './I18nContext'; @@ -26,7 +32,8 @@ const logger: ReturnType = createPackageCom 'I18nProvider', ); -const I18N_LANGUAGE_STORAGE_KEY: string = 'asgardeo-i18n-language'; +const DEFAULT_STORAGE_KEY: string = 'asgardeo-i18n-language'; +const DEFAULT_URL_PARAM: string = 'lang'; export interface I18nProviderProps { /** @@ -35,42 +42,86 @@ export interface I18nProviderProps { preferences?: I18nPreferences; } -/** - * Detects the browser's default language or returns a fallback - */ const detectBrowserLanguage = (): string => { if (typeof window !== 'undefined' && window.navigator) { - return window.navigator.language || 'en-US'; + return window.navigator.language || TranslationBundleConstants.FALLBACK_LOCALE; } - return 'en-US'; + + return TranslationBundleConstants.FALLBACK_LOCALE; }; -/** - * Gets the stored language from localStorage or returns null - */ -const getStoredLanguage = (): string | null => { - if (typeof window !== 'undefined' && window.localStorage) { - try { - return window.localStorage.getItem(I18N_LANGUAGE_STORAGE_KEY); - } catch (error) { - // localStorage might not be available or accessible - return null; - } +const deriveRootDomain = (hostname: string): string => { + const parts: string[] = hostname.split('.'); + return parts.length > 1 ? parts.slice(-2).join('.') : hostname; +}; + +const getCookie = (name: string): string | null => { + if (typeof document === 'undefined') return null; + const match: RegExpMatchArray | null = document.cookie.match( + new RegExp(`(?:^|; )${name.replace(/([.*+?^${}()|[\]\\])/g, '\\$1')}=([^;]*)`), + ); + return match ? decodeURIComponent(match[1]) : null; +}; + +const setCookie = (name: string, value: string, domain: string): void => { + if (typeof document === 'undefined') return; + const maxAge: number = 365 * 24 * 60 * 60; + const secure: string = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = + `${encodeURIComponent(name)}=${encodeURIComponent(value)}` + + `; Max-Age=${maxAge}` + + `; Path=/` + + `; Domain=${domain}` + + `; SameSite=Lax${secure}`; +}; + +interface StorageAdapter { + read: () => string | null; + write: (language: string) => void; +} + +const createStorageAdapter = (strategy: I18nStorageStrategy, key: string, cookieDomain?: string): StorageAdapter => { + switch (strategy) { + case 'cookie': + return { + read: (): string | null => getCookie(key), + write: (language: string): void => { + const domain: string = + cookieDomain ?? (typeof window !== 'undefined' ? deriveRootDomain(window.location.hostname) : ''); + if (domain) setCookie(key, language, domain); + }, + }; + case 'localStorage': + return { + read: (): string | null => { + if (typeof window === 'undefined' || !window.localStorage) return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + }, + write: (language: string): void => { + if (typeof window === 'undefined' || !window.localStorage) return; + try { + window.localStorage.setItem(key, language); + } catch { + logger.warn('Failed to persist language preference to localStorage.'); + } + }, + }; + case 'none': + default: + return {read: (): null => null, write: (): void => {}}; } - return null; }; -/** - * Stores the language in localStorage - */ -const storeLanguage = (language: string): void => { - if (typeof window !== 'undefined' && window.localStorage) { - try { - window.localStorage.setItem(I18N_LANGUAGE_STORAGE_KEY, language); - } catch (error) { - // localStorage might not be available or accessible - logger.warn('Failed to store language preference:'); - } +const detectUrlParamLanguage = (paramName: string): string | null => { + if (typeof window === 'undefined') return null; + try { + return new URLSearchParams(window.location.search).get(paramName); + } catch { + return null; } }; @@ -85,18 +136,35 @@ const I18nProvider: FC> = ({ // Get default bundles from the browser package const defaultBundles: Record = getDefaultI18nBundles(); - // Determine the initial language based on preference order: - // 1. User preference from config - // 2. Stored language in localStorage - // 3. Browser's default language - // 4. Fallback language + const storageStrategy: I18nStorageStrategy = preferences?.storageStrategy ?? 'cookie'; + const storageKey: string = preferences?.storageKey ?? DEFAULT_STORAGE_KEY; + const urlParamConfig: string | false = preferences?.urlParam === undefined ? DEFAULT_URL_PARAM : preferences.urlParam; + + const resolvedCookieDomain: string | undefined = useMemo((): string | undefined => { + if (storageStrategy !== 'cookie') return undefined; + if (preferences?.cookieDomain) return preferences.cookieDomain; + return typeof window !== 'undefined' ? deriveRootDomain(window.location.hostname) : undefined; + }, [storageStrategy, preferences?.cookieDomain]); + + const storage: StorageAdapter = useMemo( + () => createStorageAdapter(storageStrategy, storageKey, resolvedCookieDomain), + [storageStrategy, storageKey, resolvedCookieDomain], + ); + const determineInitialLanguage = (): string => { - const configLanguage: string | undefined = preferences?.language; - const storedLanguage: string | null = getStoredLanguage(); + if (preferences?.language) return preferences.language; + if (urlParamConfig !== false) { + const urlLanguage: string | null = detectUrlParamLanguage(urlParamConfig); + if (urlLanguage) { + storage.write(urlLanguage); + return urlLanguage; + } + } + const storedLanguage: string | null = storage.read(); + if (storedLanguage) return storedLanguage; const browserLanguage: string = detectBrowserLanguage(); - const fallbackLanguage: string = preferences?.fallbackLanguage || 'en-US'; - - return configLanguage || storedLanguage || browserLanguage || fallbackLanguage; + if (browserLanguage) return browserLanguage; + return preferences?.fallbackLanguage || TranslationBundleConstants.FALLBACK_LOCALE; }; const [currentLanguage, setCurrentLanguage] = useState(determineInitialLanguage); @@ -110,13 +178,16 @@ const I18nProvider: FC> = ({ setInjectedBundles((prev: Record) => { const merged: Record = {...prev}; Object.entries(newBundles).forEach(([key, bundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + bundle.translations as unknown as Record>, + ); if (merged[key]) { merged[key] = { ...merged[key], - translations: deepMerge(merged[key].translations, bundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = bundle; + merged[key] = {...bundle, translations: normalizedTranslations}; } }); return merged; @@ -140,27 +211,33 @@ const I18nProvider: FC> = ({ // 2. Injected bundles (e.g., from flow metadata) — override defaults Object.entries(injectedBundles).forEach(([key, bundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + bundle.translations as unknown as Record>, + ); if (merged[key]) { merged[key] = { ...merged[key], - translations: deepMerge(merged[key].translations, bundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = bundle; + merged[key] = {...bundle, translations: normalizedTranslations}; } }); // 3. User-provided bundles (from props) — highest priority, override everything if (preferences?.bundles) { Object.entries(preferences.bundles).forEach(([key, userBundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + userBundle.translations as unknown as Record>, + ); if (merged[key]) { merged[key] = { ...merged[key], metadata: userBundle.metadata ? {...merged[key].metadata, ...userBundle.metadata} : merged[key].metadata, - translations: deepMerge(merged[key].translations, userBundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = userBundle; + merged[key] = {...userBundle, translations: normalizedTranslations}; } }); } @@ -168,12 +245,12 @@ const I18nProvider: FC> = ({ return merged; }, [defaultBundles, injectedBundles, preferences?.bundles]); - const fallbackLanguage: string = preferences?.fallbackLanguage || 'en-US'; + const fallbackLanguage: string = preferences?.fallbackLanguage || TranslationBundleConstants.FALLBACK_LOCALE; - // Update stored language when current language changes + // Persist language changes to the configured storage. useEffect(() => { - storeLanguage(currentLanguage); - }, [currentLanguage]); + storage.write(currentLanguage); + }, [currentLanguage, storage]); // Translation function const t: (key: string, params?: Record) => string = useCallback( diff --git a/packages/react/src/hooks/useTranslation.ts b/packages/react/src/hooks/useTranslation.ts index 3a0e122ea..826898af6 100644 --- a/packages/react/src/hooks/useTranslation.ts +++ b/packages/react/src/hooks/useTranslation.ts @@ -16,9 +16,10 @@ * under the License. */ -import {deepMerge, I18nPreferences} from '@asgardeo/browser'; -import {I18nBundle} from '@asgardeo/i18n'; +import {deepMerge, 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'; export interface UseTranslation { @@ -60,6 +61,8 @@ export interface UseTranslationWithPreferences extends UseTranslation { */ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationWithPreferences => { const context: any = useContext(I18nContext); + const componentPrefs: Preferences | undefined = useContext(ComponentPreferencesContext); + const contextPreferences: I18nPreferences | undefined = componentPrefs?.i18n; if (!context) { throw new Error( @@ -67,11 +70,14 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW ); } + // Direct parameter takes precedence over context-provided preferences + const effectivePreferences: I18nPreferences | undefined = componentPreferences ?? contextPreferences; + const {t: globalT, currentLanguage, setLanguage, bundles: globalBundles, fallbackLanguage} = context; // Merge global bundles with component-level bundles if provided const mergedBundles: Record = useMemo(() => { - if (!componentPreferences?.bundles) { + if (!effectivePreferences?.bundles) { return globalBundles; } @@ -83,7 +89,10 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW }); // Merge component-level bundles using deepMerge for better merging - Object.entries(componentPreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundle]) => { + Object.entries(effectivePreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + componentBundle.translations as unknown as Record>, + ); if (merged[key]) { // Deep merge component bundle with existing global bundle merged[key] = { @@ -91,20 +100,20 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW metadata: componentBundle.metadata ? {...merged[key].metadata, ...componentBundle.metadata} : merged[key].metadata, - translations: deepMerge(merged[key].translations, componentBundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { // No global bundle for this language, use component bundle as-is - merged[key] = componentBundle; + merged[key] = {...componentBundle, translations: normalizedTranslations}; } }); return merged; - }, [globalBundles, componentPreferences?.bundles]); + }, [globalBundles, effectivePreferences?.bundles]); // Create enhanced translation function that uses merged bundles const enhancedT: (key: string, params?: Record) => string = useMemo(() => { - if (!componentPreferences?.bundles) { + if (!effectivePreferences?.bundles) { // No component preferences, use global translation function return globalT; } @@ -114,14 +123,14 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW // Try to get translation from current language bundle const currentBundle: I18nBundle | undefined = mergedBundles[currentLanguage]; - if (currentBundle?.translations[key]) { + if (currentBundle?.translations?.[key]) { translation = currentBundle.translations[key]; } // Fallback to fallback language if translation not found if (!translation && currentLanguage !== fallbackLanguage) { const fallbackBundle: I18nBundle | undefined = mergedBundles[fallbackLanguage]; - if (fallbackBundle?.translations[key]) { + if (fallbackBundle?.translations?.[key]) { translation = fallbackBundle.translations[key]; } } @@ -142,7 +151,7 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW return translation; }; - }, [mergedBundles, currentLanguage, fallbackLanguage, globalT, componentPreferences?.bundles]); + }, [mergedBundles, currentLanguage, fallbackLanguage, globalT, effectivePreferences?.bundles]); return { availableLanguages: Object.keys(mergedBundles), diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4c51b7ac0..b13cc3bc5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -193,6 +193,16 @@ export type { export {CreateOrganization} from './components/presentation/CreateOrganization/CreateOrganization'; +export {default as BaseLanguageSwitcher} from './components/presentation/LanguageSwitcher/BaseLanguageSwitcher'; +export type { + BaseLanguageSwitcherProps, + LanguageOption, + LanguageSwitcherRenderProps, +} from './components/presentation/LanguageSwitcher/BaseLanguageSwitcher'; + +export {default as LanguageSwitcher} from './components/presentation/LanguageSwitcher/LanguageSwitcher'; +export type {LanguageSwitcherProps} from './components/presentation/LanguageSwitcher/LanguageSwitcher'; + export {default as Button} from './components/primitives/Button/Button'; export * from './components/primitives/Button/Button';