diff --git a/src/i18n/__tests__/use-custom-i18n.test.tsx b/src/i18n/__tests__/use-custom-i18n.test.tsx new file mode 100644 index 0000000000..c7134321cc --- /dev/null +++ b/src/i18n/__tests__/use-custom-i18n.test.tsx @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { I18nProvider, I18nProviderProps, useCustomI18n } from '../../../lib/components/i18n'; +import { MESSAGES, TestComponent } from './test-component'; + +const THIRD_PARTY_NAMESPACE = 'third-party-library'; +const OTHER_NAMESPACE = 'other-library'; +const COMPONENT = 'test-component'; + +interface ThirdPartyFormatArgTypes { + 'test-component': { + label: never; + itemCount: { count: number }; + }; +} + +interface OtherFormatArgTypes { + 'test-component': { + label: never; + }; +} + +const THIRD_PARTY_MESSAGES: I18nProviderProps.Messages = { + [THIRD_PARTY_NAMESPACE]: { + en: { + [COMPONENT]: { + label: 'Provider label', + itemCount: '{count, plural, one {# item} other {# items}}', + }, + }, + }, +}; + +const OTHER_MESSAGES: I18nProviderProps.Messages = { + [OTHER_NAMESPACE]: { + en: { + [COMPONENT]: { + label: 'Other namespace label', + }, + }, + }, +}; + +function ThirdPartyComponent({ id = 'third-party-result' }: { id?: string }) { + const i18n = useCustomI18n(THIRD_PARTY_NAMESPACE, COMPONENT); + + return ( + <> + {i18n('label', undefined)} + {i18n('itemCount', undefined, format => format({ count: 2 }))} + + ); +} + +function MultiNamespaceComponent() { + const thirdPartyI18n = useCustomI18n(THIRD_PARTY_NAMESPACE, COMPONENT); + const otherI18n = useCustomI18n(OTHER_NAMESPACE, COMPONENT); + + return ( + <> + {thirdPartyI18n('label', undefined)} + {thirdPartyI18n('itemCount', undefined, format => format({ count: 2 }))} + {otherI18n('label', undefined)} + + ); +} + +it('resolves messages independently across multiple namespaces', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('#third-party')).toHaveTextContent('Provider label'); + expect(container.querySelector('#third-party-count')).toHaveTextContent('2 items'); + expect(container.querySelector('#other')).toHaveTextContent('Other namespace label'); +}); + +it('merges messages from nested providers across namespaces', () => { + const { container } = render( + + + + + + ); + + expect(container.querySelector('#third-party')).toHaveTextContent('Provider label'); + expect(container.querySelector('#other')).toHaveTextContent('Other namespace label'); +}); + +it('allows Cloudscape and third-party components to share one provider', () => { + const { container } = render( + + + + + ); + + expect(container.querySelector('#top-level-string')).toHaveTextContent('top level string'); + expect(container.querySelector('#third-party-result')).toHaveTextContent('Provider label'); + expect(container.querySelector('#third-party-result-count')).toHaveTextContent('2 items'); +}); diff --git a/src/i18n/context.ts b/src/i18n/context.ts index c5a5f8cfc4..6f9da0095f 100644 --- a/src/i18n/context.ts +++ b/src/i18n/context.ts @@ -41,35 +41,64 @@ export function useLocale(): string | null { */ type StringKeyOf = Extract; -export interface ComponentFormatFunction> { - >( +export interface GenericI18nFormatArgTypes { + [componentName: string]: { + [messageKey: string]: Record | never; + }; +} + +export interface GenericI18nFormatFunction< + NamespaceTypes = GenericI18nFormatArgTypes, + ComponentName extends StringKeyOf = StringKeyOf, +> { + >( key: MessageKey, provided: string, - handler?: CustomHandler + handler?: CustomHandler ): string; - >( + >( key: MessageKey, provided: string | undefined, - handler?: CustomHandler + handler?: CustomHandler ): string | undefined; - , ReturnValue>( + , ReturnValue>( key: MessageKey, provided: ReturnValue, - handler: I18nFormatArgTypes[ComponentName][MessageKey] extends never + handler: NamespaceTypes[ComponentName][MessageKey] extends never ? never - : CustomHandler + : CustomHandler ): ReturnValue; } -export function useInternalI18n>( - componentName: ComponentName -): ComponentFormatFunction { +export type I18nFormatFunction< + NamespaceTypes = GenericI18nFormatArgTypes, + ComponentName extends StringKeyOf = StringKeyOf, +> = GenericI18nFormatFunction; + +export type ComponentFormatFunction> = I18nFormatFunction< + I18nFormatArgTypes, + ComponentName +>; + +/** + * Public hook for third-party component libraries to resolve translations + * through I18nProvider under their own namespace. + */ +export function useCustomI18n< + NamespaceTypes = GenericI18nFormatArgTypes, + ComponentName extends StringKeyOf = StringKeyOf, +>(namespace: string, componentName: ComponentName): I18nFormatFunction { const { format } = useContext(InternalI18nContext) ?? defaultContextValue; - return , ValueType>( + const formatFunction = , ValueType>( key: MessageKey, provided: ValueType, - customHandler?: CustomHandler - ) => { - return format(namespace, componentName, key, provided, customHandler); - }; + customHandler?: CustomHandler + ) => format(namespace, componentName, key, provided, customHandler); + return formatFunction; +} + +export function useInternalI18n>( + componentName: ComponentName +): ComponentFormatFunction { + return useCustomI18n(namespace, componentName); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index fd66f773c7..0284fa4e8a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -4,3 +4,5 @@ 'use client'; export { I18nProvider as default, I18nProvider, I18nProviderProps } from './provider'; export { importMessages } from './dynamic'; +export { useCustomI18n } from './context'; +export type { I18nFormatFunction } from './context';