Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/i18n/__tests__/use-custom-i18n.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ThirdPartyFormatArgTypes, typeof COMPONENT>(THIRD_PARTY_NAMESPACE, COMPONENT);

return (
<>
<span id={id}>{i18n('label', undefined)}</span>
<span id={`${id}-count`}>{i18n('itemCount', undefined, format => format({ count: 2 }))}</span>
</>
);
}

function MultiNamespaceComponent() {
const thirdPartyI18n = useCustomI18n<ThirdPartyFormatArgTypes, typeof COMPONENT>(THIRD_PARTY_NAMESPACE, COMPONENT);
const otherI18n = useCustomI18n<OtherFormatArgTypes, typeof COMPONENT>(OTHER_NAMESPACE, COMPONENT);

return (
<>
<span id="third-party">{thirdPartyI18n('label', undefined)}</span>
<span id="third-party-count">{thirdPartyI18n('itemCount', undefined, format => format({ count: 2 }))}</span>
<span id="other">{otherI18n('label', undefined)}</span>
</>
);
}

it('resolves messages independently across multiple namespaces', () => {
const { container } = render(
<I18nProvider messages={[THIRD_PARTY_MESSAGES, OTHER_MESSAGES]} locale="en">
<MultiNamespaceComponent />
</I18nProvider>
);

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(
<I18nProvider messages={[THIRD_PARTY_MESSAGES]} locale="en">
<I18nProvider messages={[OTHER_MESSAGES]} locale="en">
<MultiNamespaceComponent />
</I18nProvider>
</I18nProvider>
);

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(
<I18nProvider messages={[MESSAGES, THIRD_PARTY_MESSAGES]} locale="en">
<TestComponent />
<ThirdPartyComponent />
</I18nProvider>
);

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');
});
61 changes: 45 additions & 16 deletions src/i18n/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,35 +41,64 @@ export function useLocale(): string | null {
*/
type StringKeyOf<T> = Extract<keyof T, string>;

export interface ComponentFormatFunction<ComponentName extends StringKeyOf<I18nFormatArgTypes>> {
<MessageKey extends StringKeyOf<I18nFormatArgTypes[ComponentName]>>(
export interface GenericI18nFormatArgTypes {
[componentName: string]: {
[messageKey: string]: Record<string, string | number> | never;
};
}

export interface GenericI18nFormatFunction<
NamespaceTypes = GenericI18nFormatArgTypes,
ComponentName extends StringKeyOf<NamespaceTypes> = StringKeyOf<NamespaceTypes>,
> {
<MessageKey extends StringKeyOf<NamespaceTypes[ComponentName]>>(
key: MessageKey,
provided: string,
handler?: CustomHandler<string, I18nFormatArgTypes[ComponentName][MessageKey]>
handler?: CustomHandler<string, NamespaceTypes[ComponentName][MessageKey]>
): string;
<MessageKey extends StringKeyOf<I18nFormatArgTypes[ComponentName]>>(
<MessageKey extends StringKeyOf<NamespaceTypes[ComponentName]>>(
key: MessageKey,
provided: string | undefined,
handler?: CustomHandler<string, I18nFormatArgTypes[ComponentName][MessageKey]>
handler?: CustomHandler<string | undefined, NamespaceTypes[ComponentName][MessageKey]>
): string | undefined;
<MessageKey extends StringKeyOf<I18nFormatArgTypes[ComponentName]>, ReturnValue>(
<MessageKey extends StringKeyOf<NamespaceTypes[ComponentName]>, ReturnValue>(
key: MessageKey,
provided: ReturnValue,
handler: I18nFormatArgTypes[ComponentName][MessageKey] extends never
handler: NamespaceTypes[ComponentName][MessageKey] extends never
? never
: CustomHandler<ReturnValue, I18nFormatArgTypes[ComponentName][MessageKey]>
: CustomHandler<ReturnValue, NamespaceTypes[ComponentName][MessageKey]>
): ReturnValue;
}

export function useInternalI18n<ComponentName extends StringKeyOf<I18nFormatArgTypes>>(
componentName: ComponentName
): ComponentFormatFunction<ComponentName> {
export type I18nFormatFunction<
NamespaceTypes = GenericI18nFormatArgTypes,
ComponentName extends StringKeyOf<NamespaceTypes> = StringKeyOf<NamespaceTypes>,
> = GenericI18nFormatFunction<NamespaceTypes, ComponentName>;

export type ComponentFormatFunction<ComponentName extends StringKeyOf<I18nFormatArgTypes>> = 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<NamespaceTypes> = StringKeyOf<NamespaceTypes>,
>(namespace: string, componentName: ComponentName): I18nFormatFunction<NamespaceTypes, ComponentName> {
const { format } = useContext(InternalI18nContext) ?? defaultContextValue;
return <MessageKey extends StringKeyOf<I18nFormatArgTypes[ComponentName]>, ValueType>(
const formatFunction = <MessageKey extends StringKeyOf<NamespaceTypes[ComponentName]>, ValueType>(
key: MessageKey,
provided: ValueType,
customHandler?: CustomHandler<ValueType, I18nFormatArgTypes[ComponentName][MessageKey]>
) => {
return format(namespace, componentName, key, provided, customHandler);
};
customHandler?: CustomHandler<ValueType, NamespaceTypes[ComponentName][MessageKey]>
) => format(namespace, componentName, key, provided, customHandler);
return formatFunction;
}

export function useInternalI18n<ComponentName extends StringKeyOf<I18nFormatArgTypes>>(
componentName: ComponentName
): ComponentFormatFunction<ComponentName> {
return useCustomI18n<I18nFormatArgTypes, ComponentName>(namespace, componentName);
}
2 changes: 2 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading