17 KiB
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 <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.