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

276 lines
17 KiB
Markdown
Raw 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 -->
# 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:
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.