first commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# `react-i18n.suspense.*` fallback bundle
|
||||
|
||||
## Purpose
|
||||
|
||||
Ships the strings that `<I18nSuspense>` needs before the active catalog
|
||||
loads its own copies. Consumers can override these keys by adding entries
|
||||
under the same dotted path in their own catalog (the catalog's `t()` will
|
||||
prefer the consumer-provided value). Without this bundle the screen-
|
||||
reader-only loading label would either be hardcoded English (violating
|
||||
the package-wide no-hardcoded-strings rule) or empty (a poor a11y
|
||||
experience while a backend round-trip is in flight).
|
||||
|
||||
The bundle is intentionally tiny — only the strings rendered inside the
|
||||
React-i18n package itself live here. Application-facing copy belongs in
|
||||
the consumer's own catalog.
|
||||
|
||||
## Features
|
||||
|
||||
- One frozen object: `REACT_I18N_FALLBACK_BUNDLE`
|
||||
- Two-token interface: `ReactI18nFallbackKey` enumerates every key
|
||||
- Stringly-typed values: zero runtime cost, plain JSON
|
||||
- Consumer override: any key the active catalog defines wins
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_I18N_FALLBACK_BUNDLE,
|
||||
type ReactI18nFallbackKey,
|
||||
} from "@suaveplan/react-i18n/components/i18n-suspense/fallback-bundle";
|
||||
|
||||
const label = REACT_I18N_FALLBACK_BUNDLE["react-i18n.suspense.loading"];
|
||||
// ^? string
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Resolving a fallback key with the consumer-provided override taking
|
||||
precedence over the bundled default:
|
||||
|
||||
```ts
|
||||
import { REACT_I18N_FALLBACK_BUNDLE } from "@suaveplan/react-i18n/components/i18n-suspense/fallback-bundle";
|
||||
|
||||
function resolveLoading(i18n: { catalog: { has(k: string): boolean; t(k: string): string } }): string {
|
||||
const key = "react-i18n.suspense.loading";
|
||||
return i18n.catalog.has(key)
|
||||
? i18n.catalog.t(key)
|
||||
: REACT_I18N_FALLBACK_BUNDLE[key];
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `REACT_I18N_FALLBACK_BUNDLE: Record<ReactI18nFallbackKey, string>`
|
||||
|
||||
Frozen mapping of fallback keys to their default English strings.
|
||||
|
||||
### `ReactI18nFallbackKey`
|
||||
|
||||
Union of every legal key in the bundle. Currently:
|
||||
|
||||
- `'react-i18n.suspense.loading'`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bundle ships in source form (no JSON parse cost) so there is zero
|
||||
runtime initialisation overhead.
|
||||
- New keys added here MUST be documented in this file and surfaced
|
||||
through the public exports of the consuming module so consumers know
|
||||
which keys are eligible for override.
|
||||
- The strings are deliberately concise. Suspense fallbacks are short-lived;
|
||||
long sentences confuse screen-reader users who navigate by character.
|
||||
@@ -0,0 +1,122 @@
|
||||
# `<I18nSuspense>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<I18nSuspense>` is a thin convenience wrapper around React's built-in
|
||||
`<Suspense>` boundary. It gives a translation-aware default fallback
|
||||
that announces loading state to assistive technology via
|
||||
`aria-live="polite"`, increments the
|
||||
`react_i18n.suspense.fallback.total` OTel counter every time the
|
||||
fallback renders, and resolves the fallback label through the active
|
||||
catalog so consumers can localise the loading announcement without
|
||||
forking the component. The wrapper exists so consumers do not need to
|
||||
re-implement the same a11y + telemetry boilerplate around every
|
||||
namespace boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Default fallback with `role="status"`, `aria-live="polite"`, and
|
||||
`aria-busy="true"` for screen-reader-friendly loading state
|
||||
- Visually hidden loading label so the announcement does not push DOM
|
||||
layout around
|
||||
- Counter `react_i18n.suspense.fallback.total{ namespace, locale }`
|
||||
records every fallback render
|
||||
- Loading label resolves from the active catalog with the bundled
|
||||
English string (`react-i18n.suspense.loading`) as the fallback
|
||||
- Custom `fallback` prop bypasses the default entirely (consumer-owned
|
||||
spinner, skeleton, or loading screen)
|
||||
- Zero hardcoded user-facing strings — every visible string flows
|
||||
through a translation key
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Wrap a subtree that may suspend during a backend-driven namespace load:
|
||||
|
||||
```tsx
|
||||
import { I18nSuspense } from "@suaveplan/react-i18n/components/i18n-suspense";
|
||||
|
||||
<I18nSuspense namespace="auth">
|
||||
<LoginForm />
|
||||
</I18nSuspense>;
|
||||
```
|
||||
|
||||
When `LoginForm` reads from a not-yet-loaded namespace and `useTranslation`
|
||||
throws the preload promise, the wrapper renders the default fallback,
|
||||
records the metric sample, and re-renders the children once the load
|
||||
resolves.
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Custom fallback + per-namespace telemetry segmentation:
|
||||
|
||||
```tsx
|
||||
import { I18nSuspense } from "@suaveplan/react-i18n/components/i18n-suspense";
|
||||
|
||||
<I18nSuspense
|
||||
namespace="checkout"
|
||||
fallback={
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
<Spinner />
|
||||
<span>{t("checkout.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CheckoutFlow />
|
||||
</I18nSuspense>;
|
||||
```
|
||||
|
||||
Consumers can override the bundled loading label by adding the
|
||||
`react-i18n.suspense.loading` key to their catalog:
|
||||
|
||||
```ts
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "de-DE",
|
||||
messages: {
|
||||
"de-DE": {
|
||||
"react-i18n": {
|
||||
suspense: { loading: "Übersetzungen werden geladen…" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The default fallback will pick up the override automatically.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `I18nSuspense(props: I18nSuspenseProps): ReactElement`
|
||||
|
||||
Props:
|
||||
|
||||
- `fallback?: ReactNode` — replaces the default screen-reader-friendly
|
||||
fallback element. When omitted, the wrapper renders its built-in
|
||||
`aria-live="polite"` div.
|
||||
- `namespace?: string` — namespace label attached to the
|
||||
`react_i18n.suspense.fallback.total` counter sample. Default: `""`.
|
||||
- `children: ReactNode` — subtree wrapped by the underlying
|
||||
`<Suspense>` boundary.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default-fallback element is a child component (`DefaultFallback`)
|
||||
rather than an inline `<div>` so the `useEffect` that records the
|
||||
metric runs only when the fallback is actually rendered (Suspense
|
||||
defers child rendering, but the fallback always renders fresh on each
|
||||
suspend cycle).
|
||||
- A per-instance `useRef<boolean>` guards against React's Strict-Mode
|
||||
double-invoke so the counter records exactly once per logical
|
||||
fallback render.
|
||||
- The visually-hidden style block uses the canonical `clip:
|
||||
rect(0,0,0,0)` pattern so the label is exposed to assistive tech but
|
||||
invisible to sighted users.
|
||||
- `resolveLoadingLabel` checks `i18n.catalog.has` before `t()` so a
|
||||
missing key does not also fire a `missingKey` event for the fallback
|
||||
bundle path — the bundled string is the silent default.
|
||||
- `aria-busy` on the fallback container hints to assistive tech that the
|
||||
region is updating; combined with `role="status"` + `aria-live="polite"`
|
||||
this is the WAI-ARIA-recommended pattern for transient loading
|
||||
indicators.
|
||||
@@ -0,0 +1,138 @@
|
||||
# `<LanguageSwitcher>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<LanguageSwitcher>` is a headless render-prop component that exposes the
|
||||
language-switching surface to consumer-controlled UI. It renders no DOM
|
||||
markup of its own — the caller controls the visual treatment via the
|
||||
render-prop child. The component bundles the bookkeeping that every
|
||||
language switcher needs: it normalises the supplied locale list into
|
||||
`LocaleDescriptor` objects with derived text-direction, subscribes to
|
||||
`languageChanged` / `loaded` events through the active Provider's
|
||||
`I18nInstance`, wraps the locale-change call in `useTransition` so the
|
||||
namespace load triggered by the switch never blocks urgent updates, and
|
||||
exposes `isPending` so the caller can render a busy state.
|
||||
|
||||
## Features
|
||||
|
||||
- Headless: zero DOM, zero CSS — render any UI in the render prop
|
||||
- Locale normalisation: accepts string codes or `LocaleDescriptor` objects
|
||||
- Auto-derives `direction` (`ltr` / `rtl`) via `getTextDirection` from
|
||||
`@suaveplan/i18n`
|
||||
- `change(locale): Promise<void>` returns when the language change
|
||||
resolves so callers can chain `await change(...)`
|
||||
- `isPending` aggregates the `useTransition` flag and the awaitable
|
||||
in-flight flag so both render-time pendingness and async resolution
|
||||
are surfaced
|
||||
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { LanguageSwitcher } from "@suaveplan/react-i18n/components/language-switcher";
|
||||
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<LanguageSwitcher locales={["en-GB", "fr-FR", "ar-AE"]}>
|
||||
{({ locales, current, change, isPending }) => (
|
||||
<select
|
||||
value={current}
|
||||
disabled={isPending}
|
||||
onChange={(e) => change(e.target.value)}
|
||||
>
|
||||
{locales.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</LanguageSwitcher>
|
||||
</TranslationProvider>;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A typed dropdown with custom labels, RTL-aware ordering, and loading
|
||||
spinner:
|
||||
|
||||
```tsx
|
||||
import { LanguageSwitcher } from "@suaveplan/react-i18n/components/language-switcher";
|
||||
|
||||
<LanguageSwitcher
|
||||
locales={[
|
||||
{ code: "en-GB", name: "English", nativeName: "English" },
|
||||
{ code: "fr-FR", name: "French", nativeName: "Français" },
|
||||
{ code: "ar-AE", name: "Arabic", nativeName: "العربية", direction: "rtl" },
|
||||
]}
|
||||
>
|
||||
{({ locales, current, change, isPending }) => (
|
||||
<div role="group" aria-label="Language">
|
||||
{locales.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
type="button"
|
||||
aria-pressed={l.code === current}
|
||||
aria-busy={isPending && l.code !== current}
|
||||
dir={l.direction}
|
||||
onClick={() => {
|
||||
void change(l.code);
|
||||
}}
|
||||
>
|
||||
{l.nativeName ?? l.code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</LanguageSwitcher>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LanguageSwitcher(props: LanguageSwitcherProps): ReactElement | null`
|
||||
|
||||
Props:
|
||||
|
||||
- `locales: ReadonlyArray<string | LocaleDescriptor>` — locales to
|
||||
surface to the render-prop. Strings are upgraded to `{ code,
|
||||
direction }` descriptors via `getTextDirection`.
|
||||
- `children: (state: LanguageSwitcherState) => ReactNode` — render-prop
|
||||
child. Receives the resolved state object.
|
||||
|
||||
### `LanguageSwitcherState`
|
||||
|
||||
- `locales: readonly LocaleDescriptor[]`
|
||||
- `current: string`
|
||||
- `change: (locale: string) => Promise<void>` — wrapped in
|
||||
`useTransition`; returns when the underlying `i18n.changeLanguage`
|
||||
resolves
|
||||
- `isPending: boolean` — true while a transition or awaitable change is
|
||||
in flight
|
||||
|
||||
### `LocaleDescriptor`
|
||||
|
||||
- `code: string`
|
||||
- `name?: string` — Latin-script display name
|
||||
- `nativeName?: string` — native-script display name
|
||||
- `direction?: "ltr" | "rtl"` — derived when not supplied
|
||||
|
||||
Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a
|
||||
`TranslationProvider`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The component uses `useSyncExternalStore` so `current` reflects the
|
||||
active locale across concurrent renders.
|
||||
- `useTransition` alone surfaces pendingness only during a render. The
|
||||
component layers an explicit awaiting flag so an `await change(...)`
|
||||
in user code observes the entire async lifecycle (start → load → end)
|
||||
rather than only the React-scheduled portion.
|
||||
- `getTextDirection` is invoked once per render via `useMemo` keyed on
|
||||
`props.locales`. Stable input arrays therefore avoid re-deriving
|
||||
direction on every render.
|
||||
- The render-prop is invoked as a function, not as JSX (`children(state)`),
|
||||
so React does not treat the function as a component instance — no
|
||||
`displayName` warnings, no Strict Mode double-invocation surprise.
|
||||
@@ -0,0 +1,106 @@
|
||||
# `<MissingKeyOverlay>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<MissingKeyOverlay>` is a dev-mode visual diagnostic that surfaces every
|
||||
`missingKey` event emitted by the active `I18nInstance` as a fixed-position
|
||||
list anchored to the bottom-right corner of the viewport. The overlay
|
||||
gives engineers a passive, always-on view of translation gaps as they
|
||||
develop — no console-tab toggling, no debug flag, no polling. In
|
||||
production builds the component renders `null` and registers no event
|
||||
listener so it costs nothing once shipped.
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `missingKey` events on the Provider's instance bus
|
||||
- Renders a fixed-position list with `aria-live="polite"` so screen
|
||||
readers announce new entries without focus theft
|
||||
- Caps retained entries at the most-recent `maxEntries` (default 50);
|
||||
older entries silently drop off
|
||||
- Returns `null` in production (`process.env.NODE_ENV === 'production'`)
|
||||
- Returns `null` when no Provider is mounted yet (no listener leak)
|
||||
- Ergonomic style defaults (monospace, dark background) plus per-entry
|
||||
locale and namespace meta
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Mount the overlay once near the root of the application — it auto-tracks
|
||||
every miss for the lifetime of the active Provider:
|
||||
|
||||
```tsx
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { MissingKeyOverlay } from "@suaveplan/react-i18n/components/missing-key-overlay";
|
||||
|
||||
<TranslationProvider config={i18nConfig}>
|
||||
<App />
|
||||
<MissingKeyOverlay />
|
||||
</TranslationProvider>;
|
||||
```
|
||||
|
||||
In dev, every `t('absent.key')` call appears in the overlay. In a
|
||||
production bundle the component compiles to a no-op return.
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A custom cap with explicit prod override and a portal mount so the
|
||||
overlay sits in a dedicated DOM root:
|
||||
|
||||
```tsx
|
||||
import { createPortal } from "react-dom";
|
||||
import { MissingKeyOverlay } from "@suaveplan/react-i18n/components/missing-key-overlay";
|
||||
|
||||
function DiagnosticsPortal() {
|
||||
const root = document.getElementById("diagnostics-root");
|
||||
if (!root) return null;
|
||||
return createPortal(
|
||||
<MissingKeyOverlay
|
||||
// Force the overlay on even in a prod build for QA staging
|
||||
production={false}
|
||||
maxEntries={200}
|
||||
/>,
|
||||
root
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MissingKeyOverlay(props?: MissingKeyOverlayProps): ReactElement | null`
|
||||
|
||||
Props:
|
||||
|
||||
- `maxEntries?: number` — hard cap on retained entries. Default: `50`.
|
||||
- `production?: boolean` — override for the runtime env-check. When
|
||||
`true`, the component returns `null` and does not subscribe.
|
||||
|
||||
Returns: `null` in production, when no Provider ancestor is mounted, or
|
||||
when no `missingKey` events have fired yet. Otherwise returns the
|
||||
fixed-position overlay.
|
||||
|
||||
### `MissingKeyEntry`
|
||||
|
||||
- `id: number` — monotonically increasing counter (used as React key)
|
||||
- `key: string` — the missing translation key
|
||||
- `locale: string` — locale at the moment the event was observed
|
||||
- `namespace: string` — namespace label (empty string when no namespace)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The component uses `useEffect` (not `useSyncExternalStore`) for the bus
|
||||
subscription because the entry list is a piece of internal React state
|
||||
driven by the bus rather than a snapshot of an external store. The two
|
||||
patterns are interchangeable here, and `useEffect` is simpler to
|
||||
reason about for a buffer with bounded capacity.
|
||||
- The append-and-cap helper (`appendCapped`) does the slice work outside
|
||||
the React update so the overlay never re-allocates the entire history
|
||||
on every event — only the bounded tail.
|
||||
- The fixed-position styles are applied inline so the overlay is
|
||||
self-contained: no external CSS file, no Tailwind dependency, no risk
|
||||
of the host app's stylesheet hiding it.
|
||||
- The `production` prop exists to make the prod-path testable without
|
||||
mutating `process.env.NODE_ENV`. The default reads `isProductionEnv()`
|
||||
on every render so consumers do not need to thread the flag through
|
||||
manually in their own builds.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `stringify-children`
|
||||
|
||||
## Purpose
|
||||
|
||||
Helpers that translate a `<Trans>` `children` block into a canonical
|
||||
ICU message body string so the message parser renders a missing-key
|
||||
fallback identically to a catalog entry. The module exists as a sibling
|
||||
of `<Trans>` (rather than inline) so the trans component file stays
|
||||
within the per-file size budget and so the structurally-invalid-child
|
||||
branch can be unit-tested directly without going through React's JSX
|
||||
surface (which validates and rejects invalid children before they
|
||||
reach our `Children.forEach` callback).
|
||||
|
||||
## Features
|
||||
|
||||
- `childrenToMessageString` — entry point that walks `Children.forEach`
|
||||
and concatenates each child's stringified form
|
||||
- `stringifyChild` — single-child stringifier, exported for direct unit
|
||||
testing of the defensive `TRANS_INVALID_CHILD` throw
|
||||
- Nested children are recursed; tag-wrapped children render as
|
||||
`<tagName>...</tagName>` with the same component-map semantics
|
||||
- Boolean, `null`, and `undefined` children render as the empty string
|
||||
(matches React's own rendering rules)
|
||||
- Defensive throw of `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)`
|
||||
for any structurally-invalid value (functions, custom objects)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { childrenToMessageString } from "@suaveplan/react-i18n/components/trans/stringify-children";
|
||||
|
||||
const body = childrenToMessageString(
|
||||
<>Hello <strong>{"{name}"}</strong>!</>,
|
||||
"absent.key"
|
||||
);
|
||||
// body === "Hello <strong>{name}</strong>!"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The helpers operate purely on React children, so consumers can use
|
||||
them outside `<Trans>` for snapshot / logging surfaces where a
|
||||
React-children tree needs a stable string serialisation:
|
||||
|
||||
```ts
|
||||
import { stringifyChild } from "@suaveplan/react-i18n/components/trans/stringify-children";
|
||||
|
||||
function snapshotChild(node: React.ReactNode): string {
|
||||
try {
|
||||
return stringifyChild(node, "snapshot");
|
||||
} catch (err) {
|
||||
return `[invalid: ${(err as Error).message}]`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `childrenToMessageString(children: ReactNode, key: string): string`
|
||||
|
||||
Walks `React.Children.forEach` over `children` and concatenates each
|
||||
result of `stringifyChild`. Returns the literal `key` string when
|
||||
`children` is `null` or `undefined`.
|
||||
|
||||
### `stringifyChild(child: ReactNode, key: string): string`
|
||||
|
||||
Stringifies one React child. Recurses into element children via
|
||||
`childrenToMessageString` for the tag body. Throws
|
||||
`ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` with `context: { key }`
|
||||
for structurally-invalid input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The module is intentionally side-effect-free; both helpers are pure
|
||||
string returners with the single exception of the defensive throw.
|
||||
- Element children with no `props.children` produce `<tagName></tagName>`
|
||||
rather than `<tagName>key</tagName>` — the nested-children helper
|
||||
reads the children prop with a fall-through to the empty string so
|
||||
the recursion does not mistakenly inject the outer key into a
|
||||
childless tag body.
|
||||
- The defensive throw exists for the edge case where TypeScript's
|
||||
`ReactNode` typing is bypassed by a caller who hand-constructs a
|
||||
child via `React.createElement`. In typical JSX usage React's own
|
||||
pre-render validation rejects invalid values before our callback
|
||||
runs.
|
||||
143
docs/localization/i18n/react-i18n/src/components/trans/trans.md
Normal file
143
docs/localization/i18n/react-i18n/src/components/trans/trans.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# `<Trans>`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`<Trans>` renders rich-text translations whose message body contains HTML-like
|
||||
tag interpolation. The component looks up `i18nKey` in the active catalog,
|
||||
parses the resolved string through `MessageParser`, and calls
|
||||
`formatChunks<ReactNode>` so each `<tagName>` placeholder maps to a JSX
|
||||
element supplied via the `components` prop. When the key is missing from
|
||||
the catalog, the component falls back to `children` parsed as a default
|
||||
ICU message, increments the missing-key telemetry counter, and still
|
||||
performs tag interpolation against the provided `components` map. This
|
||||
makes `<Trans>` the right surface for anything that mixes plain text with
|
||||
inline elements (links, bold, code, footnote markers) — `useTranslation`
|
||||
remains the choice for plain-text values.
|
||||
|
||||
## Features
|
||||
|
||||
- ICU tag interpolation with arbitrarily deep nesting (`<a><b>nested</b></a>`)
|
||||
- Plural-branch selection driven by the `count` prop with automatic
|
||||
`{count}` injection into the interpolation values
|
||||
- Default-message fallback: `children` are stringified back to an ICU body
|
||||
when the key is absent from the catalog
|
||||
- Missing-key telemetry: `react_i18n.missing_key.total{ locale, namespace,
|
||||
componentName? }` increments on every fallback render
|
||||
- Render-duration histogram: `react_i18n.render.duration_ms` records the
|
||||
wall-clock cost of `formatChunks`
|
||||
- Concurrent-safe subscription: re-renders on `languageChanged` and
|
||||
`loaded` via `useSyncExternalStore`
|
||||
- Throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` outside a Provider
|
||||
- Throws `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` when a child
|
||||
cannot be matched to a tag pair
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { Trans } from "@suaveplan/react-i18n/components/trans";
|
||||
|
||||
const i18n = createI18n({
|
||||
defaultLocale: "en-GB",
|
||||
messages: { "en-GB": { welcome: "Hello <b>{name}</b>!" } },
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<Trans
|
||||
i18nKey="welcome"
|
||||
values={{ name: "Alice" }}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The component renders the equivalent of `Hello <strong>Alice</strong>!`.
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Plural-aware rendering with nested tags and a missing-key children
|
||||
fallback that still resolves through the same component map:
|
||||
|
||||
```tsx
|
||||
import { Trans } from "@suaveplan/react-i18n/components/trans";
|
||||
|
||||
// Plural branch via count + nested tags
|
||||
<Trans
|
||||
i18nKey="cart.summary"
|
||||
count={items.length}
|
||||
values={{ name: user.name }}
|
||||
components={{
|
||||
b: <strong />,
|
||||
a: <a href="/cart" />,
|
||||
}}
|
||||
/>;
|
||||
|
||||
// Default-message fallback when the key is not yet translated
|
||||
<Trans
|
||||
i18nKey="onboarding.intro"
|
||||
values={{ product: "Suaveplan" }}
|
||||
components={{ em: <em /> }}
|
||||
>
|
||||
Welcome to <em>{"{product}"}</em>!
|
||||
</Trans>;
|
||||
```
|
||||
|
||||
When `cart.summary` is `{ one: '<b>1</b> item by <a>{name}</a>', other:
|
||||
'<b>{count}</b> items by <a>{name}</a>' }`, the catalog's `t()` selects
|
||||
the right branch and `formatChunks` substitutes the JSX tags. The fallback
|
||||
example will render as `Welcome to <em>Suaveplan</em>!` even when
|
||||
`onboarding.intro` is absent from the catalog.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Trans(props: TransProps): ReactElement`
|
||||
|
||||
Props:
|
||||
|
||||
- `i18nKey: string` — lookup key in the active catalog. Required.
|
||||
- `values?: Record<string, unknown>` — interpolation variable map. The
|
||||
`count` prop is auto-merged into this map under key `count`.
|
||||
- `components?: Record<string, ReactElement>` — tag-name → JSX element
|
||||
map used by `formatChunks` to substitute `<tagName>...</tagName>`
|
||||
placeholders.
|
||||
- `count?: number` — drives plural-branch selection through the
|
||||
catalog's `t(key, { count })` path.
|
||||
- `namespace?: string` — optional namespace label attached to telemetry
|
||||
samples; does NOT alter lookup (the namespace is encoded in `i18nKey`).
|
||||
- `componentName?: string` — optional caller-supplied component name
|
||||
attached to missing-key and render-duration samples.
|
||||
- `children?: ReactNode` — default ICU message used on key miss; element
|
||||
children serialise to `<tagName>...</tagName>` via `displayName` /
|
||||
function `name`.
|
||||
|
||||
Throws:
|
||||
|
||||
- `ReactI18nError(REACT_I18N.NO_PROVIDER)` — when rendered outside a
|
||||
`TranslationProvider` ancestor.
|
||||
- `ReactI18nError(REACT_I18N.TRANS_INVALID_CHILD)` — when a fallback
|
||||
child is neither string, number, boolean, null/undefined, nor a valid
|
||||
React element (e.g. a function passed directly as a child).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The parser instance is module-level (`TRANS_PARSER`) so it is reused
|
||||
across every render — the parser is stateless so this is safe.
|
||||
- `cloneElement` is used to inject children into each tag-mapped element;
|
||||
the original `components` map is never mutated.
|
||||
- Boolean, null, and undefined children are rendered as the empty string
|
||||
to match React's own rendering rules. Only structurally invalid
|
||||
children (functions, custom objects) trigger `TRANS_INVALID_CHILD`.
|
||||
- The component does NOT memoise the rendered output beyond what
|
||||
`useMemo` provides for the message-resolution step. Re-renders on
|
||||
parent prop changes always re-run `formatChunks` so React diffs see the
|
||||
latest tag elements.
|
||||
- Telemetry is recorded after `formatChunks` returns so the histogram
|
||||
reflects the parse + render cost rather than the full React commit
|
||||
cycle. The Provider's bus subscription handles re-render scheduling.
|
||||
Reference in New Issue
Block a user