import React, { ComponentType, createContext, ReactNode, Children, useContext, Fragment, cloneElement, isValidElement, forwardRef, ReactElement, Ref, ComponentPropsWithoutRef, ComponentPropsWithRef, ElementType, PropsWithChildren } from "react"; import { parseTemplate } from "./template"; import { LangContext, LangContextProps, TextPropType } from "./context"; import { TString } from "./string"; import { TDictionaryRoot } from "./dictionary"; type TPrefix = { [P in keyof T as `t-${string & P}`]?: T[P] | [string]; }; type PolyRef = ComponentPropsWithRef["ref"]; type AsProperty = { as?: C }; type PropsToOmit = keyof (AsProperty & P); type WithName = T & { displayName?: string | undefined }; type PolyProp = PropsWithChildren< Props & AsProperty > & Omit, PropsToOmit>; type PolyPropRef = PolyProp & { ref?: PolyRef; }; const TContext = createContext(new LangContext()); /** * Hook that gets the currently active translation context. Here's an example * of a component that wraps the `Intl.DateTimeFormat` API using the translation * context. * * ```typescript * const TDateFormat: ComponentType<{ date: Date }> = ({ date }) => { * // Get the context * const ctx = useTranslation(); * // Use context's languages stack to find a format for our locale * const dtf = new Intl.DateTimeFormat(ctx.languages); * // Find out which language was matched... * const { locale } = dtf.resolvedOptions(); * const ts = TString.literal(dtf.format(date), locale); * return ; * }; * ``` * * @returns the active translation context * @category Hooks */ export const useTranslation = (): LangContext => useContext(TContext); export type TranslateLocalProps = LangContextProps & { children: ReactNode }; /** * Wrap components in a nested [[`LangContext`]]. Used to override settings in * the context. For example we can add an additional dictionary. * * ```typescript * const Miscount = ({ children }: { children: ReactNode }) => { * // pretend one is three * const dict = { $$dict: { one: { en: "three" } } }; * return {children}; * }; * ``` * @category Components */ export const TranslateLocal: ComponentType = ({ children, ...props }): ReactElement => { const ctx = useTranslation().derive(props); return {children}; }; type TranslateProps = PolyProp; type TranslateComponent = WithName< ( props: TranslateProps ) => ReactElement | null >; /** * Wrap components in a nested [[`LangContext`]] that establishes a new * language stack. By default any children will be wrapped in a `div` with * a `lang=` property that indicates the language of the wrapped content. * * Within this context any content which can't be translated into the requested * languages will have it's own `lang=` property to reflect the fact that it * is in a different language than expected. * * ```typescript * // Renders as
....
* const Welsh: ComponentType<{ children: ReactNode }> = ({ children }) => ( * {children} * ); * * // Renders as
....
* const WelshSection: ComponentType<{ children: ReactNode }> = ({ children }) => ( * * {children} * * ); * ``` * * Unlike [[`TranslateLocal`]] `Translate` always wraps the translated * content in an element with a `lang=` property. * * @category Components */ export const Translate: TranslateComponent = ({ as, children, ...props }: TranslateProps) => { const ctx = useTranslation().derive(props); return ( {children} ); }; type AsProps = PolyPropRef; type AsComponent = WithName< (props: AsProps) => ReactElement | null >; export const As: AsComponent = forwardRef( ( { as, children, ...rest }: AsProps, ref?: PolyRef ) => { const Component = as || "span"; return ( {children} ); } ); As.displayName = "As"; type TTextProps = PolyPropRef; type TTextComponent = WithName< (props: TTextProps) => ReactElement | null >; export const TText: TTextComponent = forwardRef( ( { as, lang, children, ...props }: TTextProps, ref?: PolyRef ) => { const ctx = useTranslation(); if (lang !== ctx.ambience) { const ctxProps = ctx.retainAmbience ? { lang: lang } : { ambient: lang }; return ( {children} ); } return ( {children} ); } ); TText.displayName = "TText"; interface TFormatProps { format: string; lang: string; children: ReactNode; ref?: Ref; } const TFormat: ComponentType = forwardRef( ({ format, lang, children }: TFormatProps, ref): ReactElement => { const clone = (elt: ReactNode, props?: any): ReactNode => { if (isValidElement(elt)) return cloneElement(elt, props); if (process.env.NODE_ENV !== "production") throw new Error(`Can't add props to a non-element`); }; const parts = parseTemplate(format); // Bail out quickly in the simple case if (parts.length === 1 && typeof parts[0] === "string") return {parts[0]}; // Make children into a regular array of nodes const params = Children.toArray(children); if (process.env.NODE_ENV !== "production") if (ref && params.length !== 1) // Passing a ref is a special case which only allows // a single child throw new Error(`Can only forward refs to single children`); // Set of available indexes const avail = new Set(params.map((_x: any, i: number) => i + 1)); const dict: TDictionaryRoot = { $$dict: {} }; // Output nodes const out = parts.map(part => { if (typeof part === "string") return part; const { index, name, text } = part; if (name && text) dict.$$dict[name] = TString.literal(text, lang).dictionary; if (index < 1 || index > params.length) throw new Error( `Arg out of range %${index} (1..${params.length} are valid)` ); if (!avail.has(index)) throw new Error(`Already using arg %${index}`); // Mark it used avail.delete(index); // If we're passing a ref clone it in. Only do this to the first // parameter. This check pretty redundant - there's a check above // that enforces singularity in the ref passed case. if (ref && index === 1) return clone(params[index - 1], { ref }); return params[index - 1]; }); if (process.env.NODE_ENV !== "production") if (avail.size) throw new Error(`Unused args: ${avail}`); if (Object.keys(dict.$$dict).length) return {out}; return {out}; } ); TFormat.displayName = "TFormat"; function resolveTranslationProps( ctx: LangContext, tag?: string, text?: TextPropType ): TString { const r = () => { if (process.env.NODE_ENV !== "production") if (tag && text) throw new Error(`Got both tag and text`); if (text) return ctx.resolve(text); if (tag) return ctx.resolve([tag]); // istanbul ignore next - can't happen throw new Error(`No text or tag`); }; return r().toLang(ctx.languages); } /** * Properties for the `` component. */ export type TProps = PolyPropRef< C, { /** * A tag to look up in the current dictionary and perform template substitution on. */ tag?: string; /** * Text to translate. Can also refer to a dictionary tag if it's a single element array: * `["tag"]`. The resolved text is processed with template substitution. */ text?: TextPropType; /** * Text to translate without any further template substitution. Use for e.g. literal * translated text from an API. */ content?: TextPropType; /** * The number of the thing being described for cases where the translation provides * pluralisation rules. Defaults to 1. */ count?: number; children?: ReactNode; } > & TPrefix>; type TComponent = WithName< (props: TProps) => ReactElement | null >; const noRef = (ref: PolyRef) => { if (ref) throw new Error(`Can't pass ref`); }; /** * A wrapper for content that should be translated. It attempts to translate * the content you give it according to the active [[`LangContext`]]. It can * translate content looked up in the translation dictionary and fat strings * (or [[`TString`]]s). * * It can optionally perform template substitution on the translated text, * allowing child components to render portions of the translated text * with arbitrary wrappers. * * By default translated text is wrapped in a `span`. Render a different * element using the `as` property. * * If the wrapped content can't be translated into the context's preferred * language it will have a `lang=` property specifying its actual language. * * The simplest usage is to render translatable content without template * substition: * * ```typescript * // Render multilingual content * const hi = { en: "Hello", de: "Hallo", fr: "Bonjour" }; * return ; * // fr: Bonjour * ``` * * Template substitution allows you to build whole component trees from a * translated string: * * ```typescript * const info = { * en: "Here's a %1[useful link] and here's some %2[italic text]", * fr: "Voici %2[du texte en italique] et un %1[lien utile]", * de: "Hier ist ein %1[nützlicher Link] und hier ein %2[kursiver Text]" * }; * return ( * * * * * ); * // fr: * //
* // Voici du texte en italique et un lien utile * //
* ``` * * You can also look up and translate dictionary tags: * * ```typescript * // Same as the previous example if `info` is in the dictionary * return ( * * * * * ); * ``` * * See [Using T](/Interminimal/index.html#using-t) for more examples. * * @category Components */ export const T: TComponent = forwardRef( ( { as, tag, text, content, count, children, ...props }: TProps, ref?: PolyRef ) => { const ctx = useTranslation(); if (content) { if (process.env.NODE_ENV !== "production") { if (tag || text) throw new Error(`Please don't mix content with tag or text`); noRef(ref); } const ts = ctx.translate(content); return ( {ts.toString(count)} ); } if (tag || text) { const ts = resolveTranslationProps(ctx, tag, text); return ( {children} ); } if (process.env.NODE_ENV !== "production") noRef(ref); return ( {children} ); } ); T.displayName = "T"; const boundMap = new Map(); /** * Create a new component that behaves like `` but with a different default * `as` element. * * ```typescript * const Toption = tBind("option"); * // later * return * ``` * * It's also possible to wrap React components. * * ```typescript * const TImage = tBind(Image as FunctionComponent); * ``` * * The need for the cast is ugly. Not sure how to fix that. PRs welcome... * * The generated components are cached - so whenever you call `tBind("p")` you * will get the same component. * * @category Utilities */ // Type '{ as: C; ref: ForwardedRef>>; } & Omit, "children"> & { ...; }' // is not assignable to type 'IntrinsicAttributes & { tag?: string | undefined; text?: TextPropType | undefined; content?: TextPropType | undefined; count?: number | undefined; } & AsProperty<...> & { ...; } & Omit<...> & { ...; }'. // Type '{ as: C; ref: ForwardedRef>>; } & Omit, "children"> & { ...; }' // is not assignable to type 'Omit>, "text" | "as" | "content" | "tag" | "count">'.ts(2322) export const tBind = ( as: C ): ComponentType> => { const bind = (as: C) => { const bound = forwardRef( ( { children, ...props }: TProps, ref?: PolyRef ) => ( // @ts-ignore hmm... {children} ) ); const asName = typeof as === "string" ? as : as.displayName; if (asName) { bound.displayName = `T${asName}`; Object.defineProperty(bound, "name", { value: bound.displayName }); } return bound; }; let bound = boundMap.get(as); if (!bound) boundMap.set(as, (bound = bind(as))); return bound; };