# Spec Delta — react-ui-i18n (new capability) This delta authors a NEW capability spec `react-ui-i18n` describing the i18n surface that `@suaveplan/react-ui` exposes to consumers. It does NOT replace or duplicate `@suaveplan/react-i18n` — it consumes it. The broader `react-ui` capability spec is in-flight separately and is not in scope here. ## ADDED Requirements ### Requirement: ReactUiI18nBridge mounts third-party locale + direction context The package SHALL expose ``. On mount, the bridge SHALL: 1. Read `useLocale()` and `useDirection()` from `@suaveplan/react-i18n`. 2. Mount `@radix-ui/react-direction`'s `` so every Radix-derived primitive descendant in the tree (popover, dropdown-menu, dialog, tooltip, select, menubar, context-menu, hover-card, scroll-area, tabs, toggle-group, …) inherits the active direction without per-component wiring. 3. Expose the active locale through a stable `LocaleContext` consumed by `` defaults and ``. 4. When `syncDocumentAttributes` is `true` (default), update `document.documentElement.lang` and `document.documentElement.dir` whenever the active locale or direction changes. When `false`, document attributes SHALL NOT be touched (SSR / nested-app scenarios). 5. When `toasterDir` is `true` (default), forward direction to any `` from `sonner` mounted as a descendant. The bridge SHALL be safe to mount inside `` and SHALL be a no-op (zero DOM, zero re-renders) when no `` is mounted in the same tree. **Verified by:** `src/i18n/react-ui-i18n-bridge/react-ui-i18n-bridge.test.tsx`. #### Scenario: Radix popover inherits direction from bridge - GIVEN locale `ar-AE` (RTL) and a tree `` - WHEN the popover opens - THEN the rendered Radix root carries `dir="rtl"` (verified by `data-radix-direction` attribute or equivalent) #### Scenario: document attributes update on locale change - GIVEN `` and an active locale switch from `en-GB` to `ar-AE` - WHEN the locale change settles - THEN `document.documentElement.lang` is `"ar-AE"` and `document.documentElement.dir` is `"rtl"` #### Scenario: syncDocumentAttributes={false} leaves DOM untouched - GIVEN `` and an initial `` - WHEN locale changes to `ar-AE` - THEN `document.documentElement.lang` and `dir` are unchanged ### Requirement: TanStack Table locale-aware default sort and filter The package SHALL expose `localeAwareSortingFn(locale: string): SortingFn` and `localeAwareFilterFn(locale: string): FilterFn` from `react-ui/i18n/tanstack-bridge`. Both SHALL be backed by `Intl.Collator(locale)` so sort and case-insensitive filter respect the active locale's collation rules. The `` `formatter:` shortcut SHALL auto-install these as the column's defaults; consumers SHALL be able to opt in globally via the bridge. **Verified by:** `src/i18n/tanstack-bridge/tanstack-bridge.test.tsx`. #### Scenario: Swedish collation orders ä after z - GIVEN locale `sv-SE` and column data `["a", "z", "ä"]` - WHEN `localeAwareSortingFn("sv-SE")` is applied - THEN the resulting order is `["a", "z", "ä"]` (Swedish collation), not `["a", "ä", "z"]` (default) ### Requirement: shadcn registry includes new primitives and survives ejection Every new primitive (`FormattedNumber`, `FormattedCurrency`, `FormattedDate`, `FormattedList`, `FormattedRelativeTime`, `Plural`, `FormattedMessage`, `InputNumber`, `ReactUiI18nBridge`) SHALL ship with a `registry/r/.json` entry that: - Declares `@suaveplan/react-i18n` in `registryDependencies` so the shadcn CLI installs the binding when ejected. - Lists every source file the component depends on under `files`. Existing modified components (Drawer, Sheet, Calendar, DataTable, Pagination) SHALL have their registry entries regenerated to capture the new imports. The package SHALL expose `scripts/check-registry-eject.ts` invoked via `bun run check:registry-eject` that, for every registry entry: 1. Resolves the entry's `files` to source. 2. Applies the registry transform (rewriting `@suaveplan/react-ui` imports to local relative paths, preserving `@suaveplan/react-i18n` imports). 3. Type-checks the transformed source against a synthesized consumer `tsconfig.json` and the expected `@suaveplan/react-i18n` import shape. The script SHALL exit non-zero on any registry entry whose transformed source fails to type-check. The script SHALL be wired into the package's pre-publish gate. **Verified by:** `scripts/check-registry-eject.test.ts`. #### Scenario: ejected FormattedNumber compiles against react-i18n - GIVEN the registry transform applied to `formatted-number.json`'s files - WHEN the transformed source is type-checked - THEN `tsc --noEmit` exits 0 and the import `import { useNumber } from "@suaveplan/react-i18n"` resolves #### Scenario: missing registry entry for a public primitive fails the gate - GIVEN a public primitive added to `src/i18n/` without a corresponding `registry/r/.json` - WHEN `bun run check:registry-eject` runs - THEN the script exits non-zero with a `MISSING REGISTRY ENTRY` diagnostic ### Requirement: FormattedNumber primitive The package SHALL expose `` rendering the number through the active locale's number formatter (via `useNumber` from `@suaveplan/react-i18n`). The `as` prop SHALL accept any HTML element name or React component, defaulting to `"span"`. The component SHALL forward `className`, `style`, and any other accepted props on `as` through to the underlying element. When the active locale's `numberingSystem` is non-`latn` (e.g. `arab`, `deva`), the rendered digits SHALL match the locale's numeric script. **Verified by:** `src/i18n/formatted-number/formatted-number.test.tsx`. #### Scenario: en-GB locale renders Latin numerals with grouping - GIVEN locale `en-GB` - WHEN `` renders - THEN the rendered text is `"1,234,567"` #### Scenario: ar-AE locale renders Arabic-Indic numerals - GIVEN locale `ar-AE` - WHEN `` renders - THEN the rendered text uses Arabic-Indic digits matching the locale's numbering system #### Scenario: as prop swaps the rendered element - GIVEN `` - WHEN the component renders - THEN the resulting DOM contains a `` element with text `"42"` ### Requirement: FormattedCurrency primitive The package SHALL expose `` rendering the amount through the active locale's currency formatter (via `useCurrency`). `currency` is an ISO 4217 code; `options` mirrors the underlying `useCurrency` options shape exactly. **Verified by:** `src/i18n/formatted-currency/formatted-currency.test.tsx`. #### Scenario: USD in en-GB - GIVEN locale `en-GB` - WHEN `` renders - THEN the rendered text is `"US$9.50"` (matching `useCurrency` output) ### Requirement: FormattedDate primitive The package SHALL expose `` accepting `Date | string | number` and rendering through `useDate`. The component SHALL not throw for invalid input; it SHALL render an empty string and emit a single `console.warn` (gated on dev) with the offending value. **Verified by:** `src/i18n/formatted-date/formatted-date.test.tsx`. #### Scenario: invalid value renders empty string - GIVEN `` - WHEN the component renders - THEN the rendered text is `""` and no exception is thrown ### Requirement: FormattedList primitive The package SHALL expose `` rendering an array of strings through `useList` with the active locale's list formatter (Intl.ListFormat semantics). Empty arrays SHALL render an empty string. **Verified by:** `src/i18n/formatted-list/formatted-list.test.tsx`. #### Scenario: three-item conjunction in en-GB - GIVEN locale `en-GB` and `` - WHEN the component renders - THEN the rendered text is `"a, b, and c"` ### Requirement: FormattedRelativeTime primitive with auto-refresh The package SHALL expose `` rendering a relative time through `useRelativeTime`. When `auto` is `true`, the component SHALL re-render on `refreshIntervalMs` (default `60_000`) so the displayed string ages with the wall clock. The interval timer SHALL be cleared on unmount. Server-rendered output SHALL be deterministic — `auto` mode SHALL render its initial value on the first paint (no hydration mismatch from a clock tick during render). **Verified by:** `src/i18n/formatted-relative-time/formatted-relative-time.test.tsx` using `fakeClock` from `@suaveplan/testing/runner`. #### Scenario: auto-refresh re-renders after interval - GIVEN locale `en-GB` and `` mounted - WHEN 60 seconds advance on `fakeClock` - THEN the rendered text updates from `"30 seconds ago"` to `"1 minute ago"` without a manual re-render ### Requirement: Plural primitive selects child by CLDR rule The package SHALL expose `` selecting one child based on the active locale's CLDR plural rule for `value`. The `other` slot SHALL be required (TypeScript-enforced). Selection SHALL match `usePlural` output for the same `(locale, value)`. **Verified by:** `src/i18n/plural/plural.test.tsx`. #### Scenario: ar-AE selects different slot than en-GB for value 0 - GIVEN locale `ar-AE` and `` - WHEN the component renders - THEN the rendered text is `"ZERO"` - AND the same component under locale `en-GB` renders `"OTHER"` (English groups zero into other) ### Requirement: FormattedMessage re-export of Trans The package SHALL re-export `` as a named alias of `` from `@suaveplan/react-i18n` so consumers do not need a second import to render translated strings alongside `` etc. The branded `TranslationKey` type contract SHALL carry through. **Verified by:** `src/i18n/formatted-message/formatted-message.test.tsx`. ### Requirement: Drawer and Sheet inline-start / inline-end directions `` and `` SHALL accept `inline-start` and `inline-end` for their `direction` / `side` props respectively. These values SHALL resolve to physical directions (`left` / `right`) at render time based on `useDirection()` from `@suaveplan/react-i18n`. The existing physical direction values (`left`, `right`, `top`, `bottom`) SHALL continue to work unchanged. **Verified by:** `src/components/drawer/drawer.test.tsx`, `src/components/sheet/sheet.test.tsx`. #### Scenario: inline-start in LTR locale resolves to left - GIVEN locale `en-GB` (LTR) and `` - WHEN the drawer opens - THEN the underlying `vaul` `direction` prop is `"left"` #### Scenario: inline-start in RTL locale resolves to right - GIVEN locale `ar-AE` (RTL) and `` - WHEN the drawer opens - THEN the underlying `vaul` `direction` prop is `"right"` ### Requirement: DataTable column formatter shortcut `ColumnDef` SHALL accept an optional `formatter?: { kind: "number" | "currency" | "date" | "relative-time"; options?: ... }`. When set, the column's cell renderer SHALL wrap the raw value in the matching `` primitive. An explicit `cell:` override SHALL take precedence over `formatter:` on the same column. **Verified by:** `src/components/data-table/data-table.test.tsx`. #### Scenario: number formatter shortcut renders FormattedNumber - GIVEN a `ColumnDef` `{ accessorKey: "qty", formatter: { kind: "number" } }` - WHEN the column's cell renders for a row with `qty: 12345` - THEN the cell DOM contains the same text `` would produce #### Scenario: explicit cell beats formatter shortcut - GIVEN a `ColumnDef` with both `cell: () => "X"` and `formatter: { kind: "number" }` - WHEN the column's cell renders - THEN the rendered text is `"X"` ### Requirement: Calendar honors active locale for weekday/month names `` SHALL pass the active locale (resolved through `useLocale()` and a `localeStringToDateFnsLocale(locale)` helper) into `react-day-picker`'s `locale` prop. Weekday headers and month names SHALL render in the active locale. The `localeStringToDateFnsLocale` helper SHALL include explicit mappings for the five bundled locales (`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) and SHALL fall back to the default `enUS` locale with a single `console.warn` for unmapped locales. **Verified by:** `src/components/calendar/calendar.test.tsx`. #### Scenario: zh-CN renders Chinese month name - GIVEN locale `zh-CN` - WHEN `` is mounted on May 9 2026 - THEN the month header contains `"5月"` (or the locale-appropriate Chinese rendering) ### Requirement: InputNumber locale-aware decimal/grouping The package SHALL expose `` (locale defaults to `useLocale()`). When unfocused, the control SHALL render the value through ``. On focus, it SHALL switch to a raw editable buffer keyed off the locale's decimal separator. Parsing SHALL accept the locale's grouping and decimal separators (introspected via `Intl.NumberFormat(locale).formatToParts(12345.6)`). Empty input SHALL emit `null` through `onValueChange`. The plain `` component SHALL be untouched; `` is a sibling. **Verified by:** `src/components/input/input-number/input-number.test.tsx`. #### Scenario: ar-AE accepts Arabic decimal separator - GIVEN locale `ar-AE` and an `` mounted - WHEN the user types `"3٫14"` (using Arabic decimal separator U+066B) - THEN `onValueChange` fires with `3.14` ### Requirement: Pagination renders page numbers via FormattedNumber `` and `` SHALL render page indices through `` so non-Latin numbering systems display native digits. Existing translation keys (`react-ui.pagination.*`) SHALL be unchanged. **Verified by:** updated `src/components/pagination/pagination.test.tsx`. ### Requirement: Validation message bridge The package SHALL expose `useValidationMessageBridge()` returning a `(error: SuaveplanError) => string` function that resolves the error to a localized message via `useErrorMessage` from react-i18n. Toast, Alert, and ConfirmDeleteDialog SHALL accept an optional `error?: SuaveplanError` prop alongside their existing string props; when present, the resolved message renders. Existing string-only props remain valid. **Verified by:** `src/i18n/validation-message-bridge/validation-message-bridge.test.tsx`. #### Scenario: bridge resolves a validation error to a localized message - GIVEN a `SuaveplanError` with code `VALIDATION.MIN_LENGTH` and locale `en-GB` - WHEN a component renders `bridge(error)` - THEN the returned string matches the en-GB message bundle's value for that code ### Requirement: Translation key extraction script The package SHALL ship `scripts/extract-translation-keys.ts` and a `bun run check:i18n-keys` script. The script SHALL AST-walk every `*.tsx` under `src/components/**` and `src/i18n/**` for `t("react-ui.…")` calls, `` usages, and `useTranslation` resolutions, and SHALL diff the discovered keys against `src/locales/en-GB.json`. The script SHALL exit non-zero when (a) any key referenced in code is missing from the canonical en-GB bundle, or (b) any key in the bundle is not referenced anywhere in code. Output SHALL list both `MISSING` and `ORPHAN` keys with file paths. **Verified by:** `scripts/extract-translation-keys.test.ts` (script-level test using a fixtures directory). #### Scenario: missing key fails the gate - GIVEN a component file with `t("react-ui.foo.missing")` and `en-GB.json` lacking that key - WHEN `bun run check:i18n-keys` runs - THEN the script exits non-zero and prints `MISSING: react-ui.foo.missing` with the source file path #### Scenario: orphan key fails the gate - GIVEN `en-GB.json` containing `react-ui.foo.unused` and no source file references it - WHEN `bun run check:i18n-keys` runs - THEN the script exits non-zero and prints `ORPHAN: react-ui.foo.unused` ### Requirement: Bundled translations cover every primitive The five bundled locales (`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) SHALL contain real translations for every key referenced by the new primitives, ``, the locale-aware behavior wiring, and the validation bridge surfaces. English-language placeholders in non-English bundles are NOT acceptable; bundles SHALL be reviewed before the change ships. **Verified by:** the `check:i18n-keys` gate plus a manual review note in the change archive.