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

347 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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.`