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>(