From 52c7a7f3f4817129e20c343fe470788f3a648dbd Mon Sep 17 00:00:00 2001 From: radsacha <235808464+radsacha@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:30:50 -0700 Subject: [PATCH 1/3] feat: Add public i18n hook --- src/i18n/__tests__/use-i18n.test.tsx | 180 +++++++++++++++++++++++++++ src/i18n/context.ts | 15 +++ src/i18n/index.ts | 2 + 3 files changed, 197 insertions(+) create mode 100644 src/i18n/__tests__/use-i18n.test.tsx diff --git a/src/i18n/__tests__/use-i18n.test.tsx b/src/i18n/__tests__/use-i18n.test.tsx new file mode 100644 index 0000000000..b8de5aeae5 --- /dev/null +++ b/src/i18n/__tests__/use-i18n.test.tsx @@ -0,0 +1,180 @@ +// 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, useI18n } 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'; + +const THIRD_PARTY_MESSAGES: I18nProviderProps.Messages = { + [THIRD_PARTY_NAMESPACE]: { + en: { + [COMPONENT]: { + label: 'Provider label', + itemCount: '{count, plural, one {# item} other {# items}}', + }, + }, + }, +}; + +function ThirdPartyComponent({ + id = 'result', + namespace = THIRD_PARTY_NAMESPACE, + component = COMPONENT, + messageKey = 'label', + provided, +}: { + id?: string; + namespace?: string; + component?: string; + messageKey?: string; + provided?: string; +}) { + const i18n = useI18n(namespace, component); + return {i18n(messageKey, provided)}; +} + +function ThirdPartyComponentWithHandler() { + const i18n = useI18n(THIRD_PARTY_NAMESPACE, COMPONENT); + return {i18n('itemCount', undefined, format => format({ count: 2 }))}; +} + +function MultiNamespaceComponent() { + const thirdPartyI18n = useI18n(THIRD_PARTY_NAMESPACE, COMPONENT); + const otherI18n = useI18n(OTHER_NAMESPACE, COMPONENT); + + return ( + <> + {thirdPartyI18n('label', undefined)} + {otherI18n('label', undefined)} + + ); +} + +it('resolves messages from I18nProvider using a custom namespace', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('#result')).toHaveTextContent('Provider label'); +}); + +it('prefers provided strings over provider messages', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('#result')).toHaveTextContent('Provided label'); +}); + +it('returns undefined when no provider is present and no string is provided', () => { + let resolvedValue: string | undefined = 'initial value'; + let providedValue: string | undefined; + + function CaptureResolvedValue() { + const i18n = useI18n(THIRD_PARTY_NAMESPACE, COMPONENT); + resolvedValue = i18n('label', undefined); + providedValue = i18n('label', 'Provided label'); + return null; + } + + render(); + + expect(resolvedValue).toBeUndefined(); + expect(providedValue).toBe('Provided label'); +}); + +it('falls back to a less specific locale for custom namespaces', () => { + const japaneseMessages: I18nProviderProps.Messages = { + [THIRD_PARTY_NAMESPACE]: { + ja: { + [COMPONENT]: { + label: 'Japanese label', + }, + }, + }, + }; + + const { container } = render( + + + + ); + + expect(container.querySelector('#result')).toHaveTextContent('Japanese label'); +}); + +it('invokes customHandler with a format function', () => { + const { container } = render( + + + + ); + + expect(container.querySelector('#result')).toHaveTextContent('2 items'); +}); + +it('resolves messages independently across multiple namespaces', () => { + const otherMessages: I18nProviderProps.Messages = { + [OTHER_NAMESPACE]: { + en: { + [COMPONENT]: { + label: 'Other namespace label', + }, + }, + }, + }; + + const { container } = render( + + + + ); + + expect(container.querySelector('#third-party')).toHaveTextContent('Provider label'); + expect(container.querySelector('#other')).toHaveTextContent('Other namespace label'); +}); + +it('merges messages from nested providers across namespaces', () => { + const otherMessages: I18nProviderProps.Messages = { + [OTHER_NAMESPACE]: { + en: { + [COMPONENT]: { + label: 'Nested provider label', + }, + }, + }, + }; + + const { container } = render( + + + + + + ); + + expect(container.querySelector('#third-party')).toHaveTextContent('Provider label'); + expect(container.querySelector('#other')).toHaveTextContent('Nested provider 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'); +}); diff --git a/src/i18n/context.ts b/src/i18n/context.ts index c5a5f8cfc4..4bc5b4af6d 100644 --- a/src/i18n/context.ts +++ b/src/i18n/context.ts @@ -61,6 +61,21 @@ export interface ComponentFormatFunction> +) => string | undefined; + +/** + * Public hook for third-party component libraries to resolve translations + * through I18nProvider under their own namespace. + */ +export function useI18n(namespace: string, component: string): I18nFormatFunction { + const { format } = useContext(InternalI18nContext) ?? defaultContextValue; + return (key, provided, customHandler) => format(namespace, component, key, provided, customHandler); +} + export function useInternalI18n>( componentName: ComponentName ): ComponentFormatFunction { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index fd66f773c7..f18b497773 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 { useI18n } from './context'; +export type { I18nFormatFunction } from './context'; From ad83a07156d93cc94a221fb8796f88627748ab05 Mon Sep 17 00:00:00 2001 From: radsacha <235808464+radsacha@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:07:23 -0700 Subject: [PATCH 2/3] feat: Rename public i18n hook --- src/i18n/__tests__/use-custom-i18n.test.tsx | 108 ++++++++++++ src/i18n/__tests__/use-i18n.test.tsx | 180 -------------------- src/i18n/context.ts | 60 ++++--- src/i18n/index.ts | 2 +- 4 files changed, 146 insertions(+), 204 deletions(-) create mode 100644 src/i18n/__tests__/use-custom-i18n.test.tsx delete mode 100644 src/i18n/__tests__/use-i18n.test.tsx 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/__tests__/use-i18n.test.tsx b/src/i18n/__tests__/use-i18n.test.tsx deleted file mode 100644 index b8de5aeae5..0000000000 --- a/src/i18n/__tests__/use-i18n.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -// 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, useI18n } 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'; - -const THIRD_PARTY_MESSAGES: I18nProviderProps.Messages = { - [THIRD_PARTY_NAMESPACE]: { - en: { - [COMPONENT]: { - label: 'Provider label', - itemCount: '{count, plural, one {# item} other {# items}}', - }, - }, - }, -}; - -function ThirdPartyComponent({ - id = 'result', - namespace = THIRD_PARTY_NAMESPACE, - component = COMPONENT, - messageKey = 'label', - provided, -}: { - id?: string; - namespace?: string; - component?: string; - messageKey?: string; - provided?: string; -}) { - const i18n = useI18n(namespace, component); - return {i18n(messageKey, provided)}; -} - -function ThirdPartyComponentWithHandler() { - const i18n = useI18n(THIRD_PARTY_NAMESPACE, COMPONENT); - return {i18n('itemCount', undefined, format => format({ count: 2 }))}; -} - -function MultiNamespaceComponent() { - const thirdPartyI18n = useI18n(THIRD_PARTY_NAMESPACE, COMPONENT); - const otherI18n = useI18n(OTHER_NAMESPACE, COMPONENT); - - return ( - <> - {thirdPartyI18n('label', undefined)} - {otherI18n('label', undefined)} - - ); -} - -it('resolves messages from I18nProvider using a custom namespace', () => { - const { container } = render( - - - - ); - - expect(container.querySelector('#result')).toHaveTextContent('Provider label'); -}); - -it('prefers provided strings over provider messages', () => { - const { container } = render( - - - - ); - - expect(container.querySelector('#result')).toHaveTextContent('Provided label'); -}); - -it('returns undefined when no provider is present and no string is provided', () => { - let resolvedValue: string | undefined = 'initial value'; - let providedValue: string | undefined; - - function CaptureResolvedValue() { - const i18n = useI18n(THIRD_PARTY_NAMESPACE, COMPONENT); - resolvedValue = i18n('label', undefined); - providedValue = i18n('label', 'Provided label'); - return null; - } - - render(); - - expect(resolvedValue).toBeUndefined(); - expect(providedValue).toBe('Provided label'); -}); - -it('falls back to a less specific locale for custom namespaces', () => { - const japaneseMessages: I18nProviderProps.Messages = { - [THIRD_PARTY_NAMESPACE]: { - ja: { - [COMPONENT]: { - label: 'Japanese label', - }, - }, - }, - }; - - const { container } = render( - - - - ); - - expect(container.querySelector('#result')).toHaveTextContent('Japanese label'); -}); - -it('invokes customHandler with a format function', () => { - const { container } = render( - - - - ); - - expect(container.querySelector('#result')).toHaveTextContent('2 items'); -}); - -it('resolves messages independently across multiple namespaces', () => { - const otherMessages: I18nProviderProps.Messages = { - [OTHER_NAMESPACE]: { - en: { - [COMPONENT]: { - label: 'Other namespace label', - }, - }, - }, - }; - - const { container } = render( - - - - ); - - expect(container.querySelector('#third-party')).toHaveTextContent('Provider label'); - expect(container.querySelector('#other')).toHaveTextContent('Other namespace label'); -}); - -it('merges messages from nested providers across namespaces', () => { - const otherMessages: I18nProviderProps.Messages = { - [OTHER_NAMESPACE]: { - en: { - [COMPONENT]: { - label: 'Nested provider label', - }, - }, - }, - }; - - const { container } = render( - - - - - - ); - - expect(container.querySelector('#third-party')).toHaveTextContent('Provider label'); - expect(container.querySelector('#other')).toHaveTextContent('Nested provider 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'); -}); diff --git a/src/i18n/context.ts b/src/i18n/context.ts index 4bc5b4af6d..eb8be95bd8 100644 --- a/src/i18n/context.ts +++ b/src/i18n/context.ts @@ -41,50 +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 type I18nFormatFunction = ( - key: string, - provided: string | undefined, - customHandler?: CustomHandler> -) => string | undefined; +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 useI18n(namespace: string, component: string): I18nFormatFunction { +export function useCustomI18n< + NamespaceTypes = GenericI18nFormatArgTypes, + ComponentName extends StringKeyOf = StringKeyOf, +>(namespace: string, componentName: ComponentName): I18nFormatFunction { const { format } = useContext(InternalI18nContext) ?? defaultContextValue; - return (key, provided, customHandler) => format(namespace, component, key, provided, customHandler); + const formatFunction = , ValueType>( + key: MessageKey, + provided: ValueType, + customHandler?: CustomHandler + ) => format(namespace, componentName, key, provided, customHandler); + return formatFunction as I18nFormatFunction; } export function useInternalI18n>( componentName: ComponentName ): ComponentFormatFunction { - const { format } = useContext(InternalI18nContext) ?? defaultContextValue; - return , ValueType>( - key: MessageKey, - provided: ValueType, - customHandler?: CustomHandler - ) => { - return format(namespace, componentName, key, provided, customHandler); - }; + return useCustomI18n(namespace, componentName); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index f18b497773..0284fa4e8a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -4,5 +4,5 @@ 'use client'; export { I18nProvider as default, I18nProvider, I18nProviderProps } from './provider'; export { importMessages } from './dynamic'; -export { useI18n } from './context'; +export { useCustomI18n } from './context'; export type { I18nFormatFunction } from './context'; From 8d6230240007c25d1c119601cff6505e3b796000 Mon Sep 17 00:00:00 2001 From: radsacha <235808464+radsacha@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:52:23 -0700 Subject: [PATCH 3/3] chore: remove unnecessary i18n cast --- src/i18n/context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/context.ts b/src/i18n/context.ts index eb8be95bd8..6f9da0095f 100644 --- a/src/i18n/context.ts +++ b/src/i18n/context.ts @@ -94,7 +94,7 @@ export function useCustomI18n< provided: ValueType, customHandler?: CustomHandler ) => format(namespace, componentName, key, provided, customHandler); - return formatFunction as I18nFormatFunction; + return formatFunction; } export function useInternalI18n>(