Files
2026-05-27 23:29:53 +01:00

22 KiB
Raw Permalink Blame History

Capability Spec: react-ui-i18n

Package: @suaveplan/react-ui (the i18n surface) Status: SHIPPED 2026-05-10 (react-i18n-plugin-registry-and-ui-primitives archived)

This capability describes the i18n surface that @suaveplan/react-ui exposes to consumers — formatted-value primitives, locale-aware behavior wiring on existing components, third-party library bridges, and the validation message bridge. It consumes @suaveplan/react-i18n (registry, hooks, contexts) and is the load-bearing surface for the React stack's i18n integration.

Requirements

Requirement: ReactUiI18nBridge mounts third-party locale + direction context

The package SHALL expose <ReactUiI18nBridge children syncDocumentAttributes? toasterDir?/>. On mount, the bridge SHALL:

  1. Read useLocale() and useDirection() from @suaveplan/react-i18n.
  2. Mount @radix-ui/react-direction's <DirectionProvider dir={direction}> 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 <DataTable> defaults and <Calendar>.
  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 <Toaster> from sonner mounted as a descendant.

The bridge SHALL be safe to mount inside <ReactUiProvider> and SHALL be a no-op (zero DOM, zero re-renders) when no <TranslationProvider> 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 <TranslationProvider><ReactUiI18nBridge><Popover/></ReactUiI18nBridge></TranslationProvider>
  • 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 <ReactUiI18nBridge syncDocumentAttributes/> 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 <ReactUiI18nBridge syncDocumentAttributes={false}/> and an initial <html lang="en">
  • 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<TData> and localeAwareFilterFn(locale: string): FilterFn<TData> 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 <DataTable> 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/<name>.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/<name>.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 <FormattedNumber value={n} options? as?/> 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 <FormattedNumber value={1234567}/> renders
  • THEN the rendered text is "1,234,567"

Scenario: ar-AE locale renders Arabic-Indic numerals

  • GIVEN locale ar-AE
  • WHEN <FormattedNumber value={1234}/> renders
  • THEN the rendered text uses Arabic-Indic digits matching the locale's numbering system

Scenario: as prop swaps the rendered element

  • GIVEN <FormattedNumber value={42} as="strong"/>
  • WHEN the component renders
  • THEN the resulting DOM contains a <strong> element with text "42"

Requirement: FormattedCurrency primitive

The package SHALL expose <FormattedCurrency amount currency options? as?/> 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 <FormattedCurrency amount={9.5} currency="USD"/> renders
  • THEN the rendered text is "US$9.50" (matching useCurrency output)

Requirement: FormattedDate primitive

The package SHALL expose <FormattedDate value options? as?/> 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 <FormattedDate value={"not-a-date"}/>
  • WHEN the component renders
  • THEN the rendered text is "" and no exception is thrown

Requirement: FormattedList primitive

The package SHALL expose <FormattedList items options? as?/> 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 <FormattedList items={["a","b","c"]}/>
  • WHEN the component renders
  • THEN the rendered text is "a, b, and c"

Requirement: FormattedRelativeTime primitive with auto-refresh

The package SHALL expose <FormattedRelativeTime value unit? options? auto? refreshIntervalMs?/> 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 <FormattedRelativeTime value={Date.now() - 30_000} auto/> 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 <Plural value zero? one few? many? two? other/> 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 <Plural value={0} zero="ZERO" one="ONE" few="FEW" many="MANY" other="OTHER"/>
  • 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 <FormattedMessage> as a named alias of <Trans> from @suaveplan/react-i18n so consumers do not need a second import to render translated strings alongside <FormattedNumber/> etc. The branded TranslationKey<KeyPath> type contract SHALL carry through.

Verified by: src/i18n/formatted-message/formatted-message.test.tsx.

Requirement: Drawer and Sheet inline-start / inline-end directions

<Drawer> and <Sheet> 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 <Drawer direction="inline-start"/>
  • 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 <Drawer direction="inline-start"/>
  • WHEN the drawer opens
  • THEN the underlying vaul direction prop is "right"

Requirement: DataTable column formatter shortcut

ColumnDef<TData> 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 <FormattedX/> 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 <FormattedNumber value={12345}/> 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

<Calendar> 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 <Calendar/> 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 <InputNumber value? defaultValue? onValueChange? locale? /> (locale defaults to useLocale()). When unfocused, the control SHALL render the value through <FormattedNumber/>. 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 <Input> component SHALL be untouched; <InputNumber> 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 <InputNumber/> 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

<Pagination> and <PaginationStatus> SHALL render page indices through <FormattedNumber/> 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, <Trans i18nKey="react-ui.…" /> 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, <InputNumber>, 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.

Requirement: Stories ship for every i18n primitive

Every primitive added by the react-ui-i18n capability SHALL ship a sibling .stories.tsx alongside its implementation. Each story file SHALL expose a Default, a Playground, and at least one further story (the canonical third — Variants, WithI18n, or a more specific name like LTR vs RTL). Stories SHALL render inside the shared <StoriesShell> so the provider stack matches production composition. A consolidated story-snapshot test SHALL exercise every story's render() to assert it produces a tree without throwing.

Verified by: src/i18n/i18n-stories.test.tsx (48 scenarios across the 11 covered surfaces).

Scenario: every i18n primitive has a .stories.tsx sibling

  • GIVEN the directory src/i18n/<primitive>/ (or src/components/input/input-number/ for <InputNumber>)
  • WHEN the i18n-stories.test.tsx aggregate snapshot test runs
  • THEN every covered surface reports the required Default and Playground stories plus at least one further variant story

Requirement: bun run check:i18n-keys catches translation-key drift

The package SHALL ship scripts/check-i18n-keys.ts invoked via bun run check:i18n-keys. The script SHALL extract every react-ui.* translation-key reference from the documented chassis call shapes (t("react-ui.…"), tDynamic(...), i18nKey="…", i18nKey: "…", <name>Key={"…"}, ?? "react-ui.…", const DEFAULT_*_KEY = "…", labelKey: "…") under src/components/**, src/i18n/**, src/theme/**, src/pack/**, src/provider/**, src/hooks/**, and src/errors/**, then diff the discovered set against src/locales/en-GB.json. The script SHALL exit non-zero when any key is referenced in code but absent from the bundle (MISSING); ORPHAN diagnostics (bundle keys nothing in code references) SHALL be printed as warnings by default and SHALL fail the gate when invoked with --strict. The script SHALL be wired into bunx turbo lint via the package lint script.

Verified by: scripts/check-i18n-keys.test.ts (12 scenarios — pattern coverage, line-number reporting, namespace filtering, MISSING/ORPHAN/clean diagnoses).

Scenario: missing key in code fails the gate

  • GIVEN a source file referencing t("react-ui.foo.absent") 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.absent at <file>:<line>

Scenario: orphan key warns by default and fails under --strict

  • GIVEN en-GB.json containing react-ui.foo.unused and no source reference
  • WHEN bun run check:i18n-keys runs (default mode)
  • THEN the script exits zero and prints ORPHAN react-ui.foo.unused (warn — use --strict to fail)
  • AND when invoked with --strict the same condition exits non-zero

Scenario: clean source + bundle pair passes

  • GIVEN every key in en-GB.json has at least one source reference AND every code-referenced key is in the bundle
  • WHEN bun run check:i18n-keys runs
  • THEN the script exits zero with clean — N keys in code, M keys in bundle.

Requirement: bun run check:registry-eject verifies shadcn drift

The package SHALL ship scripts/check-registry-eject.ts invoked via bun run check:registry-eject. The script SHALL walk every registry/r/*.json entry (excluding index.json) and verify:

  1. The entry parses as JSON, has a non-empty name, has a type field, and has a non-empty files array.
  2. Every path in files[] resolves to an existing file under src/components/<entry-name>/.
  3. Every component directory under src/components/ whose contents include a <name>.tsx, <name>.ts, index.ts, or index.tsx SHALL have a corresponding registry/r/<name>.json entry.
  4. No registry/r/<name>.json entry SHALL exist without a matching src/components/<name>/ directory.

The script SHALL emit MISSING_REGISTRY_ENTRY, MISSING_SOURCE_FILE, EMPTY_FILES_LIST, MALFORMED_ENTRY, or ORPHAN_REGISTRY_ENTRY diagnostics and SHALL exit non-zero on any. The script SHALL be wired into bunx turbo lint. The full transform-roundtrip compile gate (running tsc --noEmit over the registry-transformed source against a synthesized consumer tsconfig.json) is deferred until the registry transform itself ships as a callable helper — today the registry ships source verbatim.

Verified by: scripts/check-registry-eject.test.ts (10 scenarios).

Scenario: missing source file fails the gate

  • GIVEN a registry/r/foo.json listing foo.tsx and src/components/foo/foo.tsx not existing
  • WHEN bun run check:registry-eject runs
  • THEN the script exits non-zero with a MISSING_SOURCE_FILE diagnostic that names the entry and the expected source path

Scenario: missing registry entry for a public component fails the gate

  • GIVEN a public component at src/components/<new>/<new>.tsx with no corresponding registry/r/<new>.json
  • WHEN bun run check:registry-eject runs
  • THEN the script exits non-zero with a MISSING_REGISTRY_ENTRY diagnostic naming the directory

Scenario: clean tree passes

  • GIVEN every registry entry has a matching source file AND every component directory has a registry entry
  • WHEN bun run check:registry-eject runs
  • THEN the script exits zero with clean — N registry entries match M component directories.