22 KiB
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:
- Read
useLocale()anduseDirection()from@suaveplan/react-i18n. - 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. - Expose the active locale through a stable
LocaleContextconsumed by<DataTable>defaults and<Calendar>. - When
syncDocumentAttributesistrue(default), updatedocument.documentElement.langanddocument.documentElement.dirwhenever the active locale or direction changes. Whenfalse, document attributes SHALL NOT be touched (SSR / nested-app scenarios). - When
toasterDiristrue(default), forward direction to any<Toaster>fromsonnermounted 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 bydata-radix-directionattribute or equivalent)
Scenario: document attributes update on locale change
- GIVEN
<ReactUiI18nBridge syncDocumentAttributes/>and an active locale switch fromen-GBtoar-AE - WHEN the locale change settles
- THEN
document.documentElement.langis"ar-AE"anddocument.documentElement.diris"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.langanddirare 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-SEand 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-i18ninregistryDependenciesso 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:
- Resolves the entry's
filesto source. - Applies the registry transform (rewriting
@suaveplan/react-uiimports to local relative paths, preserving@suaveplan/react-i18nimports). - Type-checks the transformed source against a synthesized consumer
tsconfig.jsonand the expected@suaveplan/react-i18nimport 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 --noEmitexits 0 and the importimport { 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 correspondingregistry/r/<name>.json - WHEN
bun run check:registry-ejectruns - THEN the script exits non-zero with a
MISSING REGISTRY ENTRYdiagnostic
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"(matchinguseCurrencyoutput)
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-GBand<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-GBand<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-AEand<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-GBrenders"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
vauldirectionprop 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
vauldirectionprop 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
ColumnDefwith bothcell: () => "X"andformatter: { 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-AEand an<InputNumber/>mounted - WHEN the user types
"3٫14"(using Arabic decimal separator U+066B) - THEN
onValueChangefires with3.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
SuaveplanErrorwith codeVALIDATION.MIN_LENGTHand localeen-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")anden-GB.jsonlacking that key - WHEN
bun run check:i18n-keysruns - THEN the script exits non-zero and prints
MISSING: react-ui.foo.missingwith the source file path
Scenario: orphan key fails the gate
- GIVEN
en-GB.jsoncontainingreact-ui.foo.unusedand no source file references it - WHEN
bun run check:i18n-keysruns - 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>/(orsrc/components/input/input-number/for<InputNumber>) - WHEN the
i18n-stories.test.tsxaggregate snapshot test runs - THEN every covered surface reports the required
DefaultandPlaygroundstories 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")anden-GB.jsonlacking that key - WHEN
bun run check:i18n-keysruns - 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.jsoncontainingreact-ui.foo.unusedand no source reference - WHEN
bun run check:i18n-keysruns (default mode) - THEN the script exits zero and prints
ORPHAN react-ui.foo.unused (warn — use --strict to fail) - AND when invoked with
--strictthe same condition exits non-zero
Scenario: clean source + bundle pair passes
- GIVEN every key in
en-GB.jsonhas at least one source reference AND every code-referenced key is in the bundle - WHEN
bun run check:i18n-keysruns - 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:
- The entry parses as JSON, has a non-empty
name, has atypefield, and has a non-emptyfilesarray. - Every path in
files[]resolves to an existing file undersrc/components/<entry-name>/. - Every component directory under
src/components/whose contents include a<name>.tsx,<name>.ts,index.ts, orindex.tsxSHALL have a correspondingregistry/r/<name>.jsonentry. - No
registry/r/<name>.jsonentry SHALL exist without a matchingsrc/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.jsonlistingfoo.tsxandsrc/components/foo/foo.tsxnot existing - WHEN
bun run check:registry-ejectruns - THEN the script exits non-zero with a
MISSING_SOURCE_FILEdiagnostic 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>.tsxwith no correspondingregistry/r/<new>.json - WHEN
bun run check:registry-ejectruns - THEN the script exits non-zero with a
MISSING_REGISTRY_ENTRYdiagnostic 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-ejectruns - THEN the script exits zero with
clean — N registry entries match M component directories.