diff --git a/TransWithoutContext.d.ts b/TransWithoutContext.d.ts index d8acd67f6..3ffbead34 100644 --- a/TransWithoutContext.d.ts +++ b/TransWithoutContext.d.ts @@ -1,40 +1,80 @@ -import type { i18n, ParseKeys, Namespace, TypeOptions, TOptions, TFunction } from 'i18next'; +import type { + i18n, + ApplyTarget, + ConstrainTarget, + GetSource, + ParseKeys, + Namespace, + SelectorFn, + TypeOptions, + TOptions, + TFunction, +} from 'i18next'; import * as React from 'react'; type _DefaultNamespace = TypeOptions['defaultNS']; +type _EnableSelector = TypeOptions['enableSelector']; type TransChild = React.ReactNode | Record; -export type TransProps< - Key extends ParseKeys, + +/** + * This type functionally replaces {@link TransProps}, and should replace + * it when cut over from the string-based to the selector API + * (tentatively v27). + * + * So if you depend on this type directly, just be aware that it will be + * renamed to `TransProps` in a future major release. + */ +interface TransPropsInterface< + Key, Ns extends Namespace = _DefaultNamespace, KPrefix = undefined, TContext extends string | undefined = undefined, TOpt extends TOptions & { context?: TContext } = { context: TContext }, - E = React.HTMLProps, -> = E & { +> { children?: TransChild | readonly TransChild[]; components?: readonly React.ReactElement[] | { readonly [tagName: string]: React.ReactElement }; count?: number; context?: TContext; defaults?: string; i18n?: i18n; - i18nKey?: Key | Key[]; + i18nKey?: Key; ns?: Ns; parent?: string | React.ComponentType | null; // used in React.createElement if not null tOptions?: TOpt; values?: {}; shouldUnescape?: boolean; t?: TFunction; -}; +} -export function Trans< +export type GetTransProps< + Target extends ConstrainTarget, Key extends ParseKeys, Ns extends Namespace = _DefaultNamespace, KPrefix = undefined, TContext extends string | undefined = undefined, TOpt extends TOptions & { context?: TContext } = { context: TContext }, E = React.HTMLProps, ->(props: TransProps): React.ReactElement; +> = E & + TransPropsInterface< + _EnableSelector extends true + ? SelectorFn, KPrefix>, ApplyTarget, TOpt> + : Key | Key[], + Ns, + KPrefix, + TContext, + TOpt + >; + +export declare function Trans< + Key extends ParseKeys, + const Ns extends Namespace = _DefaultNamespace, + KPrefix = undefined, + TContext extends string | undefined = undefined, + TOpt extends TOptions & { context?: TContext } = { context: TContext }, + E = React.HTMLProps, + Target extends ConstrainTarget = ConstrainTarget, +>(props: GetTransProps): React.ReactElement; export type ErrorCode = | 'NO_I18NEXT_INSTANCE' @@ -71,3 +111,31 @@ export type ErrorMeta = { * ``` */ export type ErrorArgs = readonly [string, ErrorMeta | undefined, ...any[]]; + +/** + * This type left here in case anyone in userland depends on it. + * It's no longer used internally, and can/should be removed when + * we cut over to the selector API (tentatively v27) + */ +export type TransProps< + Key extends ParseKeys, + Ns extends Namespace = _DefaultNamespace, + KPrefix = undefined, + TContext extends string | undefined = undefined, + TOpt extends TOptions & { context?: TContext } = { context: TContext }, + E = React.HTMLProps, +> = E & { + children?: TransChild | readonly TransChild[]; + components?: readonly React.ReactElement[] | { readonly [tagName: string]: React.ReactElement }; + count?: number; + context?: TContext; + defaults?: string; + i18n?: i18n; + i18nKey?: Key | Key[]; + ns?: Ns; + parent?: string | React.ComponentType | null; // used in React.createElement if not null + tOptions?: TOpt; + values?: {}; + shouldUnescape?: boolean; + t?: TFunction; +}; diff --git a/test/typescript/selector-custom-types/Trans.test.tsx b/test/typescript/selector-custom-types/Trans.test.tsx new file mode 100644 index 000000000..b0d57c416 --- /dev/null +++ b/test/typescript/selector-custom-types/Trans.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expectTypeOf } from 'vitest'; +import * as React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +describe('', () => { + describe('default namespace', () => { + it('standard usage', () => { + (expectTypeOf($.foo).toMatchTypeOf<'foo'>, $.foo)} />; + }); + + it(`raises a TypeError given a key that doesn't exist`, () => { + // @ts-expect-error + $.Nope} />; + }); + }); + + describe('named namespace', () => { + it('standard usage', () => { + (expectTypeOf($.foo).toEqualTypeOf<'foo'>, $.foo)} />; + }); + + it(`raises a TypeError given a namespace that doesn't exist`, () => { + expectTypeOf>() + .toHaveProperty('ns') + .extract<'Nope'>() + // @ts-expect-error + .toMatchTypeOf<'Nope'>(); + }); + }); + + describe('array namespace', () => { + it('should work with array namespace', () => ( + <> + (expectTypeOf($.baz).toEqualTypeOf<'baz'>(), $.baz)} + /> + (expectTypeOf($.alternate.baz).toEqualTypeOf<'baz'>(), $.baz)} + /> + (expectTypeOf($.custom.bar).toEqualTypeOf<'bar'>(), $.custom.bar)} + /> + (expectTypeOf($.alternate.baz).toEqualTypeOf<'baz'>(), $.alternate.baz)} + /> + (expectTypeOf($.bar).toEqualTypeOf<'bar'>(), $.bar)} + /> + (expectTypeOf($.custom.bar).toEqualTypeOf<'bar'>(), $.bar)} + /> + ( + expectTypeOf($.alternate.foobar.deep.deeper.deeeeeper).toEqualTypeOf<'foobar'>(), + $.alternate.foobar.deep.deeper.deeeeeper + )} + /> + + )); + + it(`raises a TypeError given a key that's not present inside any namespace`, () => { + // @ts-expect-error + $.bar} />; + // @ts-expect-error + $.custom.baz} />; + }); + }); + + describe('usage with `t` function', () => { + it('should work when providing `t` function', () => { + const { t } = useTranslation('alternate'); + (expectTypeOf($.foobar.barfoo).toEqualTypeOf<'barfoo'>(), $.foobar.barfoo)} + />; + }); + + it('should work when providing `t` function with a prefix', () => { + const { t } = useTranslation('alternate', { keyPrefix: 'foobar.deep' }); + ( + expectTypeOf($.deeper.deeeeeper).toEqualTypeOf<'foobar'>(), $.deeper.deeeeeper + )} + />; + }); + + it('raises a TypeError given a key-prefixed `t` function and an invalid key', () => { + const { t } = useTranslation('alternate', { keyPrefix: 'foobar.deep' }); + // @ts-expect-error + $.xxx} />; + }); + }); + + describe('interpolation', () => { + it('should work with text and interpolation', () => { + expectTypeOf(Trans).toBeCallableWith({ + children: <>foo {{ var: '' }}, + }); + }); + + it('should work with Interpolation in HTMLElement', () => { + expectTypeOf(Trans).toBeCallableWith({ + children: ( + <> + foo {{ var: '' }} + + ), + }); + }); + + it('should work with text and interpolation as children of an HTMLElement', () => { + expectTypeOf(Trans).toBeCallableWith({ + children: foo {{ var: '' }}, + }); + }); + }); +}); diff --git a/test/typescript/selector-custom-types/TransWithoutContext.test.tsx b/test/typescript/selector-custom-types/TransWithoutContext.test.tsx new file mode 100644 index 000000000..6aa496085 --- /dev/null +++ b/test/typescript/selector-custom-types/TransWithoutContext.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expectTypeOf, assertType } from 'vitest'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Trans } from '../../../TransWithoutContext'; + +describe('', () => { + describe('default namespace', () => { + it('standard usage', () => { + $.foo}>foo; + }); + + it("raises a TypeError given a key that doesn't exist", () => { + expectTypeOf>() + .toHaveProperty('i18nKey') + .extract<'Nope'>() + // @ts-expect-error + .toMatchTypeOf<'Nope'>(); + }); + }); + + describe('named namespace', () => { + it('standard usage', () => { + $.foo}>; + }); + + it("raises a TypeError given a namespace that doesn't exist", () => { + expectTypeOf>() + .toHaveProperty('ns') + .extract<'Nope'>() + // @ts-expect-error + .toMatchTypeOf<'Nope'>(); + }); + }); + + describe('array namespace', () => { + it('should work with array namespace', () => { + $.baz} />; + $.custom.bar} />; + $.alternate.baz} />; + $.bar} />; + $.custom.bar} />; + $.alternate.baz} />; + + /** + * TODO: figure out what to do about default/fallback values? + * + * Currently, `Trans` doesn't accept a 'defaultValue' prop, like the + * `t` function does. + * + * I could add that, or we could try something different -- wanted to get + * feedback on this before starting down any particular path + */ + // expectTypeOf(Trans).toBeCallableWith({ + // ns: ['alternate', 'custom'], + // i18nKey: ['alternate:baz', 'custom:bar'], + // }); + }); + + it('raises a TypeError given a key not present inside the current namespace', () => { + // @ts-expect-error + $.baz} />; + }); + }); + + describe('usage with `t` function', () => { + it('should work when providing `t` function', () => { + const { t } = useTranslation('alternate'); + + $.foobar.barfoo}> + foo + ; + }); + + it('should work when providing `t` function with a prefix', () => { + const { t } = useTranslation('alternate', { keyPrefix: 'foobar.deep' }); + + $.deeper.deeeeeper}> + foo + ; + }); + + it('raises a TypeError given a `t` function with key prefix when the key is invalid', () => { + const { t } = useTranslation('alternate', { keyPrefix: 'foobar.deep' }); + + // @ts-expect-error + $.xxx}> + foo + ; + }); + }); + + describe('interpolation', () => { + it('should work with text and interpolation', () => { + expectTypeOf(Trans).toBeCallableWith({ + children: <>foo {{ var: '' }}, + }); + }); + + it('should work with Interpolation in HTMLElement', () => { + expectTypeOf(Trans).toBeCallableWith({ + children: ( + <> + foo {{ var: '' }} + + ), + }); + }); + + it('should work with text and interpolation as children of an HTMLElement', () => { + expectTypeOf(Trans).toBeCallableWith({ + children: foo {{ var: '' }}, + }); + }); + }); + + describe('usage with context', () => { + it('should work with default namespace', () => { + assertType( $.some} context="me" />); + }); + + it('raises a TypeError when context is invalid', () => { + // @ts-expect-error + assertType( $.some} context="one" />); + }); + + it('should work with `ns` prop', () => { + assertType( $.beverage} />); + }); + + it('raises a TypeError given a namespace when context is invalid', () => { + assertType( + // @ts-expect-error + $.beverage} context="strawberry" />, + ); + }); + + it('should work with default `t` function', () => { + const { t } = useTranslation(); + + assertType( $.some} context="me" />); + }); + + it('raises a TypeError given a defaut `t` function when context is invalid', () => { + // @ts-expect-error should + assertType( $.some} context="Test1222" />); + }); + + it('should work with custom `t` function', () => { + const { t } = useTranslation('context'); + + assertType( $.dessert} context="cake" />); + }); + + it('raises a TypeError given a custom `t` function when context is invalid', () => { + // @ts-expect-error + assertType(); + }); + + it('should work with `ns` prop and `count` prop', () => { + const { t } = useTranslation('plurals'); + assertType( $.foo} count={2} />); + }); + + it('should work with custom `t` function and `count` prop', () => { + const { t } = useTranslation('plurals'); + assertType( $.foo} count={2} />); + }); + }); +}); diff --git a/test/typescript/selector-custom-types/Translation.test.tsx b/test/typescript/selector-custom-types/Translation.test.tsx new file mode 100644 index 000000000..96657574c --- /dev/null +++ b/test/typescript/selector-custom-types/Translation.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expectTypeOf } from 'vitest'; +import { Translation } from 'react-i18next'; +import React from 'react'; + +describe('', () => { + it('should work with default namespace', () => ( + {(t) => t(($) => $.foo)} + )); + + it('should work with named default namespace', () => ( + {(t) => t(($) => $.foo)} + )); + + it('should work with named namespace', () => ( + {(t) => t(($) => $.baz)} + )); + + it('should work with namespace array', () => { + + {(t) => `${t(($) => $.baz)} ${t(($) => $.custom.foo)}`} + ; + }); + + it("raises a TypeError given a namespace that doesn't exist", () => { + // @ts-expect-error + {(t) => t.fake}; + }); + + it("raises a TypeError given a key that's not in the namespace", () => { + // @ts-expect-error + {(t) => t.fake}; + }); + + it("raises a TypeError given a key that's not in the namespace (with namespace as prefix)", () => { + // @ts-expect-error + {(t) => t.custom.fake}; + }); +}); diff --git a/test/typescript/selector-custom-types/i18next.d.ts b/test/typescript/selector-custom-types/i18next.d.ts new file mode 100644 index 000000000..1de47cf6a --- /dev/null +++ b/test/typescript/selector-custom-types/i18next.d.ts @@ -0,0 +1,47 @@ +import 'i18next'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'custom'; + allowObjectInHTMLChildren: true; + enableSelector: true; + resources: { + custom: { + foo: 'foo'; + bar: 'bar'; + + some: 'some'; + some_me: 'some context'; + }; + + alternate: { + baz: 'baz'; + foobar: { + barfoo: 'barfoo'; + deep: { + deeper: { + deeeeeper: 'foobar'; + }; + }; + }; + }; + + plurals: { + foo_zero: 'foo'; + foo_one: 'foo'; + foo_two: 'foo'; + foo_many: 'foo'; + foo_other: 'foo'; + }; + + context: { + dessert_cake: 'a nice cake'; + dessert_muffin_one: 'a nice muffin'; + dessert_muffin_other: '{{count}} nice muffins'; + + beverage: 'beverage'; + beverage_beer: 'beer'; + }; + }; + } +} diff --git a/test/typescript/selector-custom-types/tsconfig.json b/test/typescript/selector-custom-types/tsconfig.json new file mode 100644 index 000000000..6c88073b9 --- /dev/null +++ b/test/typescript/selector-custom-types/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["./**/*"], + "exclude": [] +} diff --git a/test/typescript/selector-custom-types/useTranslation.test.ts b/test/typescript/selector-custom-types/useTranslation.test.ts new file mode 100644 index 000000000..38f30faf9 --- /dev/null +++ b/test/typescript/selector-custom-types/useTranslation.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expectTypeOf, assertType } from 'vitest'; +import { useTranslation } from 'react-i18next'; +import { TFunction, i18n } from 'i18next'; + +describe('useTranslation', () => { + it('should provide result with both object and array', () => { + const result = useTranslation(); + + expectTypeOf(result).toMatchTypeOf<[TFunction, i18n, boolean]>(); + expectTypeOf(result).toHaveProperty('ready').toBeBoolean(); + expectTypeOf(result).toHaveProperty('t').toBeFunction(); + expectTypeOf(result).toHaveProperty('i18n').toBeObject(); + }); + + describe('default namespace', () => { + it('should work with default namespace', () => { + const [t] = useTranslation(); + + expectTypeOf(t).toBeCallableWith(($) => $.foo); + }); + + it('should work with default named namespace', () => { + const [t] = useTranslation('custom'); + + expectTypeOf(t).toBeCallableWith(($) => $.bar); + }); + }); + + describe('named namespace', () => { + it('should work with named namespace', () => { + const [t] = useTranslation('alternate'); + + expectTypeOf(t).toBeCallableWith(($) => $.baz); + }); + + it(`raises a TypeError given a namespace that doesn't exist`, () => { + // @ts-expect-error + useTranslation('fake'); + }); + + it(`raises a TypeError given a key that's not in the namespace`, () => { + const [t] = useTranslation('custom'); + // @ts-expect-error + assertType(t(($) => $.fake)); + }); + }); + + describe('namespace as array', () => { + it('should work with const namespaces', () => { + const [t] = useTranslation(['alternate', 'custom']); + + expectTypeOf(t(($) => $.baz)).toEqualTypeOf<'baz'>(); + expectTypeOf(t(($) => $.baz, { ns: 'alternate' })).toEqualTypeOf<'baz'>(); + expectTypeOf(t(($) => $.custom.foo)).toEqualTypeOf<'foo'>(); + expectTypeOf(t(($) => $.foo, { ns: 'custom' })).toEqualTypeOf<'foo'>(); + }); + + it('should work with const namespaces', () => { + const namespaces = ['alternate', 'custom'] as const; + const [t] = useTranslation(namespaces); + + expectTypeOf(t(($) => $.baz)).toEqualTypeOf<'baz'>(); + expectTypeOf(t(($) => $.baz, { ns: 'alternate' })).toEqualTypeOf<'baz'>(); + expectTypeOf(t(($) => $.custom.foo)).toEqualTypeOf<'foo'>(); + expectTypeOf(t(($) => $.foo, { ns: 'custom' })).toEqualTypeOf<'foo'>(); + }); + + it('raises a TypeError given an incorrect key', () => { + const [t] = useTranslation(['custom']); + // @ts-expect-error + assertType(t(($) => $.custom.fake)); + // @ts-expect-error + assertType(t(($) => $.fake, { ns: 'custom' })); + }); + }); + + describe('with `keyPrefix`', () => { + it('should provide top-level string keys', () => { + const [t] = useTranslation('alternate', { keyPrefix: 'foobar' }); + + expectTypeOf(t(($) => $.barfoo)).toEqualTypeOf<'barfoo'>(); + }); + + it('should work with deeper objects', () => { + const [t] = useTranslation('alternate', { keyPrefix: 'foobar.deep' }); + + expectTypeOf(t(($) => $.deeper, { returnObjects: true })).toEqualTypeOf<{ + deeeeeper: 'foobar'; + }>(); + expectTypeOf(t(($) => $.deeper.deeeeeper)).toEqualTypeOf<'foobar'>(); + }); + + it('raises a TypeError given an invalid keyPrefix', () => { + // @ts-expect-error + useTranslation('alternate', { keyPrefix: 'abc' }); + }); + + it('raises a TypeError given an invalid key', () => { + const [t] = useTranslation('alternate', { keyPrefix: 'foobar' }); + // @ts-expect-error + assertType(t('abc')); + }); + }); + + it('should work with json format v4 plurals', () => { + const [t] = useTranslation('plurals'); + + expectTypeOf(t(($) => $.foo, { count: 0 })).toEqualTypeOf<'foo'>(); + }); + + it('raises a TypeError when attempting to select a pluralized key with a specific pluralized suffix', () => { + const [t] = useTranslation('plurals'); + + // @ts-expect-error + expectTypeOf(t(($) => $.foo_one)).toEqualTypeOf<'foo'>(); + }); +});