# i18n Locale — Design Spec

> Date: 2026-07-13 · Backlog: INFRA-F65 (轨道 A, i18n residual) · Non-breaking (minor)

## Problem

The published components carry hardcoded English strings that consumers cannot
localize. The npm package exports `src/canonical/*` thin wrappers, which render
`src/components/*` implementations — so the hardcoded strings in the base
implementations ship to consumers. There is no i18n / locale infrastructure in
the repo today (`grep useI18n|createI18n|LocaleProvider` → none).

### Affected sites (9)

8 `aria-label` strings + 1 visible string:

| # | Site | String | Kind |
|---|---|---|---|
| 1 | `components/Pagination/Pagination.vue` (Previous button) | `Previous` | aria-label |
| 2 | `components/Pagination/Pagination.vue` (Next button) | `Next` | aria-label |
| 3 | `components/Pagination/Pagination.vue` (page-size option) | `${n}/Page` | **visible** |
| 4 | `components/Notification/Notification.vue` (close ×) | `Close` | aria-label |
| 5 | `components/Message/Message.vue` (close ×) | `Close` | aria-label |
| 6 | `components/PopupBox/PopupBox.vue` (close ×) | `Close` | aria-label |
| 7 | `canonical/InputBoxBase.vue` (clear button) | `Clear value` | aria-label |
| 8 | `canonical/SelectBoxBase.vue` (remove chip) | `Remove option` | aria-label |
| 9 | `canonical/SelectBoxBase.vue` (clear button) | `Clear value` | aria-label |
| 10 | `components/InputNumber/InputNumber.vue` (minus button) | `decrement` | aria-label |
| 11 | `components/InputNumber/InputNumber.vue` (plus button) | `increment` | aria-label |

(11 literal occurrences; `Close` appears in 3 files, `Clear value` in 2 → 8
distinct keys.)

### Existing pattern

Some components already support per-instance override of the root aria-label via
attribute passthrough: `($attrs['aria-label'] as string | undefined) ?? 'default'`
(Chart, Slider, Switch, Select, InputNumber's input body). The 8 hardcoded sites
above are NOT wired to any override.

## Approach: lightweight locale dictionary (provide/inject)

A single app-level locale dictionary that components read via `inject`, falling
back to English defaults. This is the idiomatic Vue-library approach (Element
Plus, Vuetify, Ant Design Vue all ship a locale provider). Rejected alternative:
per-component label props (verbose, N props across N components, no central
control).

### Public API (new exports — pure addition)

```ts
// src/locale/index.ts
export interface TvuLocale {
  previous: string
  next: string
  pageUnit: string      // "Page" as used in "10/Page"
  close: string
  clearValue: string
  removeOption: string
  decrement: string
  increment: string
}

// English defaults, frozen — equal to the current hardcoded strings.
export const defaultLocale: TvuLocale

// Injection key for consumers who prefer app.provide directly.
export const TVU_LOCALE_KEY: InjectionKey<Ref<Partial<TvuLocale>>>

// Imperative helper — call inside a component setup() at the app root.
export function provideTvuLocale(locale: MaybeRefOrGetter<Partial<TvuLocale>>): void

// Internal composable used by components. Merges injected partial over defaults.
export function useLocale(): ComputedRef<TvuLocale>
```

Flat 8-key dictionary (only a handful of strings; nesting is unnecessary).

### Resolution priority (per aria-label site)

`$attrs['aria-label']` (per-instance, keep where already wired) → injected locale
key → English default. Sites without the `$attrs` pattern use `useLocale()`
directly (English default preserved → non-breaking). We do NOT add new `$attrs`
override plumbing to sites that lack it — YAGNI; the dictionary covers the
localization need.

### Reactivity

`useLocale()` returns a `ComputedRef` so a consumer providing a reactive locale
(e.g. a `ref` toggled at runtime) propagates to all components. `provideTvuLocale`
accepts a ref/getter/plain object (normalized with `toRef`/`toValue`).

### Non-breaking guarantee

`defaultLocale` values are byte-equal to the current hardcoded strings. A consumer
that never calls `provideTvuLocale` sees identical output. The visible `${n}/Page`
becomes `${n}/${locale.pageUnit}` with `pageUnit: 'Page'` default → unchanged.

## File layout

- New: `src/locale/index.ts` (interface, defaultLocale, TVU_LOCALE_KEY,
  provideTvuLocale, useLocale).
- Edit: 7 component/base files to consume `useLocale()` at the affected sites
  (Pagination, Notification, Message, PopupBox, InputBoxBase, SelectBoxBase,
  InputNumber).
- Edit: `src/index.ts` to re-export `TvuLocale` (type), `defaultLocale`,
  `TVU_LOCALE_KEY`, `provideTvuLocale`, `useLocale`.

## Testing (TDD)

- `tests/locale.test.ts` — composable unit tests:
  - `useLocale()` with no provider returns `defaultLocale`.
  - injected partial merges over defaults (only provided keys change).
  - reactive: changing the provided ref updates the computed result.
- Component tests (extend existing spec files where present):
  - Pagination: provided locale → prev/next aria-label + page-size unit reflect
    overrides; no provider → English.
  - PopupBox / Notification / Message: close aria-label localizes.
  - InputNumber: decrement/increment aria-label localizes.
  - SelectBoxBase / InputBoxBase: clear value / remove option localize.

## Out of scope

- No heavy i18n library (vue-i18n etc.).
- No locale for strings already consumer-controlled via props (titles, content).
- No bundled non-English locale packs — consumers supply their own dictionary.
- No pluralization / interpolation engine (`pageUnit` is a plain noun).
