@astryxdesign/core 0.7.1 → 0.7.2-canary.b69b2df

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  4. package/dist/MultiSelector/hooks.js +3 -1
  5. package/dist/Pagination/Pagination.d.ts +8 -5
  6. package/dist/Pagination/Pagination.d.ts.map +1 -1
  7. package/dist/Pagination/Pagination.js +21 -8
  8. package/dist/Switch/Switch.d.ts +1 -1
  9. package/dist/Switch/Switch.d.ts.map +1 -1
  10. package/dist/Switch/Switch.js +6 -0
  11. package/dist/astryx.css +1 -0
  12. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  13. package/dist/i18n/generated-locales/en.generated.js +19 -0
  14. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  15. package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
  16. package/locales/en.json +76 -0
  17. package/locales/pseudo.json +57 -0
  18. package/package.json +4 -4
  19. package/src/Button/Button.doc.mjs +2 -2
  20. package/src/Button/Button.test.tsx +155 -9
  21. package/src/Button/Button.tsx +39 -16
  22. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  23. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  24. package/src/IconButton/IconButton.doc.mjs +2 -2
  25. package/src/IconButton/IconButton.test.tsx +3 -1
  26. package/src/MultiSelector/hooks.ts +3 -1
  27. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  28. package/src/Pagination/Pagination.doc.mjs +7 -7
  29. package/src/Pagination/Pagination.spec.md +9 -9
  30. package/src/Pagination/Pagination.test.tsx +242 -0
  31. package/src/Pagination/Pagination.tsx +27 -11
  32. package/src/Switch/Switch.test.tsx +28 -0
  33. package/src/Switch/Switch.tsx +6 -0
  34. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  35. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  36. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  37. package/src/i18n/generated-locales/en.generated.ts +21 -0
  38. package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
  39. package/src/i18n/useTranslator.test.tsx +37 -0
@@ -5,9 +5,10 @@
5
5
  /**
6
6
  * @file Pagination.tsx
7
7
  * @input Uses React, StyleX, Button, Icon, Selector, Text; page number buttons delegate to Button.
8
+ * Compact first/last buttons cancel the inside control gap with logical token margins.
8
9
  * Prev/next and first/last chevrons mirror under RTL via the shared rtlStyles.mirror
9
- * (CSS scaleX), not a JS direction read. The input variant uses the chevronsLeft/chevronsRight
10
- * (first/last) icons.
10
+ * (CSS scaleX), not a JS direction read. The input and compact variants use the
11
+ * chevronsLeft/chevronsRight (first/last) icons.
11
12
  * @output Exports Pagination component, PaginationProps, PaginationVariant, PaginationSize types
12
13
  * @position Core implementation; consumed by index.ts, tested by Pagination.test.tsx
13
14
  *
@@ -105,7 +106,8 @@ export interface PaginationProps extends Omit<
105
106
  * Visual variant controlling what appears between prev/next buttons.
106
107
  * - pages: Page number buttons with ellipsis (default)
107
108
  * - count: "X–Y of Z" text
108
- * - compact: "Page X of Y" text
109
+ * - compact: "Page X of Y" text. First/last buttons are opt-in (see
110
+ * hasFirstLast).
109
111
  * - dots: Dot indicators
110
112
  * - input: An editable number box rendering "Page [ n ] / N". The leading
111
113
  * noun is controlled by `pageLabel`. First/last double-chevron buttons
@@ -122,9 +124,11 @@ export interface PaginationProps extends Omit<
122
124
  pageLabel?: string;
123
125
  /**
124
126
  * Whether to show first/last («/») double-chevron buttons flanking
125
- * prev/next. Only applies to the `input` variant; other variants ignore it.
126
- * The last button needs a known total — it is omitted when the page count is
127
- * unknown (cursor/hasMore pagination). @default true
127
+ * prev/next. Applies to the `input` variant (shown unless false) and the
128
+ * `compact` variant (shown only when true); other variants ignore it.
129
+ * The last button needs a known total — both are omitted when the page count
130
+ * is unknown (cursor/hasMore pagination).
131
+ * @default true for `input`, false for `compact`
128
132
  */
129
133
  hasFirstLast?: boolean;
130
134
  /**
@@ -181,6 +185,12 @@ const styles = stylex.create({
181
185
  alignItems: 'center',
182
186
  gap: spacingVars['--spacing-1'],
183
187
  },
188
+ compactFirst: {
189
+ marginInlineEnd: `calc(-1 * ${spacingVars['--spacing-1']})`,
190
+ },
191
+ compactLast: {
192
+ marginInlineStart: `calc(-1 * ${spacingVars['--spacing-1']})`,
193
+ },
184
194
  ellipsis: {
185
195
  display: 'inline-flex',
186
196
  alignItems: 'center',
@@ -369,7 +379,7 @@ export function Pagination({
369
379
  onPageSizeChange,
370
380
  variant = 'pages',
371
381
  pageLabel,
372
- hasFirstLast = true,
382
+ hasFirstLast: hasFirstLastProp,
373
383
  step: stepProp = 1,
374
384
  siblingCount = 1,
375
385
  size = 'md',
@@ -572,10 +582,14 @@ export function Pagination({
572
582
  const buttonSize = size === 'sm' ? 'sm' : 'md';
573
583
  const isSm = size === 'sm';
574
584
 
575
- // First/last buttons only exist in the input variant, and only when the page
576
- // count is known (they'd have no target otherwise).
577
- const showFirstLast =
578
- hasFirstLast && variant === 'input' && computedTotalPages != null;
585
+ // First/last buttons are on by default in the input variant and opt-in for
586
+ // compact; other variants never render them. They also need a known page
587
+ // count (the last button would have no target otherwise).
588
+ const hasFirstLast =
589
+ variant === 'input'
590
+ ? (hasFirstLastProp ?? true)
591
+ : variant === 'compact' && hasFirstLastProp === true;
592
+ const showFirstLast = hasFirstLast && computedTotalPages != null;
579
593
 
580
594
  const renderIndicator = () => {
581
595
  switch (variant) {
@@ -819,6 +833,7 @@ export function Pagination({
819
833
  onClick={handleFirst}
820
834
  isDisabled={isDisabled || !hasPrevious}
821
835
  isIconOnly
836
+ xstyle={variant === 'compact' && styles.compactFirst}
822
837
  />
823
838
  )}
824
839
 
@@ -874,6 +889,7 @@ export function Pagination({
874
889
  onClick={handleLast}
875
890
  isDisabled={isDisabled || !hasNext}
876
891
  isIconOnly
892
+ xstyle={variant === 'compact' && styles.compactLast}
877
893
  />
878
894
  )}
879
895
  </div>
@@ -834,6 +834,34 @@ describe('Switch', () => {
834
834
  expect(root).toHaveAttribute('id', 'switch-1');
835
835
  expect(root).toHaveAttribute('aria-label', 'Toggle notifications');
836
836
  });
837
+
838
+ it('composes a caller aria-describedby onto the switch control', () => {
839
+ const {container} = render(
840
+ <>
841
+ <p id="external-hint">Applies to every repository</p>
842
+ <Switch
843
+ label="Notifications"
844
+ description="Sends a daily digest"
845
+ value={false}
846
+ onChange={() => {}}
847
+ data-testid="my-switch"
848
+ aria-describedby="external-hint"
849
+ />
850
+ </>,
851
+ );
852
+ const control = screen.getByRole('switch');
853
+ const description = screen.getByText('Sends a daily digest');
854
+ expect(control.getAttribute('aria-describedby')?.split(' ')).toEqual([
855
+ 'external-hint',
856
+ description.id,
857
+ ]);
858
+ expect(control).toHaveAccessibleDescription(
859
+ 'Applies to every repository Sends a daily digest',
860
+ );
861
+ // The id reference lives on the control only, not on the field wrapper.
862
+ const root = container.querySelector('[data-testid="my-switch"]');
863
+ expect(root).not.toHaveAttribute('aria-describedby');
864
+ });
837
865
  });
838
866
 
839
867
  describe('pressed state', () => {
@@ -566,6 +566,7 @@ export function Switch({
566
566
  className,
567
567
  style,
568
568
  ref,
569
+ 'aria-describedby': ariaDescribedByProp,
569
570
  ...rest
570
571
  }: SwitchProps) {
571
572
  const t = useTranslator();
@@ -610,7 +611,12 @@ export function Switch({
610
611
  // Only include descriptionID when the element actually renders.
611
612
  // FieldLabel renders the description (with descriptionID) even when the
612
613
  // label is visually hidden — it's sr-only, so keep it linked.
614
+ // A consumer's own `aria-describedby` describes the control, so it comes
615
+ // first here rather than landing on the field wrapper via `...rest`.
613
616
  const describedByParts: string[] = [];
617
+ if (ariaDescribedByProp) {
618
+ describedByParts.push(ariaDescribedByProp);
619
+ }
614
620
  if (description) {
615
621
  describedByParts.push(descriptionID);
616
622
  }
@@ -21,6 +21,14 @@ export const docs = {
21
21
  usage: {
22
22
  description:
23
23
  'Wraps your app to set the active locale and (optionally) merge additional translation catalogs + per-locale overrides. Astryx components inside the subtree resolve their strings against this context. If no provider is present, components fall back to the shipped English defaults.',
24
+ anatomy: [
25
+ {
26
+ name: 'Provider boundary',
27
+ required: true,
28
+ description:
29
+ 'A nonvisual context boundary that supplies locale, message catalogs, overrides, and direction to descendant Astryx components. It renders no DOM element.',
30
+ },
31
+ ],
24
32
  bestPractices: [
25
33
  {
26
34
  guidance: true,
@@ -0,0 +1,252 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:InternationalizationProvider
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: nynexman4464
10
+ approved_at: 2026-10-15
11
+ owners: [nynexman4464, cixzhang]
12
+ review_triggers: [public-api, behavior, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/i18n/__tests__/resolve.test.ts,
16
+ packages/core/src/i18n/__tests__/InternationalizationProvider.test.tsx,
17
+ packages/core/src/i18n/__tests__/useDirection.test.tsx,
18
+ packages/core/src/i18n/__tests__/getLocaleDirection.test.ts,
19
+ packages/core/src/i18n/__tests__/useLocale.test.tsx,
20
+ packages/core/src/i18n/useTranslator.test.tsx,
21
+ ]
22
+ modules: []
23
+ families: []
24
+ design_specs: []
25
+ architecture: [architecture:internationalization]
26
+ contributing: []
27
+ system_specs: []
28
+ ---
29
+
30
+ # InternationalizationProvider component contract
31
+
32
+ ## Contract at a glance
33
+
34
+ | Area | Contract |
35
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | Public contract | `messages` accepts `ProviderMessagesByLocale`: each locale maps to a rich `Catalog` or a generated `RuntimeCatalog` string map. `RuntimeCatalog` and `ProviderMessagesByLocale` are exported from `@astryxdesign/core/i18n`. Existing props and the rich `MessagesByLocale` context shape are unchanged. |
37
+ | Behavior | Astryx strings resolve override → supplied catalog → shipped English, walking exact → parent locale. Rich and runtime catalogs resolve identically; the context always exposes the rich shape. Changing any provider prop updates the subtree. A nested provider replaces its parent's context. Without a provider: locale `en`, direction `ltr`, shipped English. |
38
+ | End-user impact | People using a translated locale see Astryx interface and assistive text in that locale where a translation exists and meaningful English otherwise; a missing translation never removes an accessible name or announcement. |
39
+ | Builder impact | Optional: import `@astryxdesign/core/locales/<tag>.generated.js` for a smaller bundle; `<tag>.json` keeps working. Render the provider once, supply partial catalogs freely, and keep DOM `dir` aligned with the provider direction. |
40
+ | Compatibility/readiness | Released default preserved; the provider, subpath, props, hooks, and rich context shape are stable public API; runtime-catalog input is additive. Re-render, nested-provider, and runtime-catalog behavior is covered by focused tests. |
41
+ | Review checks | Reject reordering fallback (FR1, ORD1); hiding a key missing from every applicable source, including shipped English (FR2); a nested provider that merges parent context (FR8); the provider setting DOM `lang`/`dir` (FR5) or persisting locale (FR4); non-string translator results (FR7); host-locale output without a provider (FR6); a context value that exposes string-map entries or resolves rich and runtime input differently (FR9). |
42
+ | Governing rules | `architecture:internationalization` INV2–INV5, INV7, INV12, INV13, INV15. |
43
+
44
+ This table is a review projection; the body below is authoritative.
45
+
46
+ ## Intent
47
+
48
+ InternationalizationProvider binds one subtree to Astryx's active locale,
49
+ translation catalogs, sparse Astryx string overrides, and direction default. It
50
+ lets Astryx components localize their own interface while applications keep
51
+ ownership of product copy and locale persistence.
52
+
53
+ ## Compatibility and migration
54
+
55
+ - Released default preserved: `yes` — a tree with no provider continues to render
56
+ shipped English strings with locale `en` and direction `ltr`.
57
+ - Compatibility class: the released provider name, `@astryxdesign/core/i18n`
58
+ subpath, props, context behavior, and hooks are stable public API.
59
+ - Controlled/uncontrolled behavior: controlled. The caller supplies locale,
60
+ messages, overrides, and optional direction on every render.
61
+ - Migration decision: a replacement provider or hook requires an accepted
62
+ compatibility and migration decision under
63
+ `architecture:internationalization/INV2`.
64
+
65
+ Consumer migration instructions belong in consumer docs and release notes.
66
+
67
+ ## Ownership boundary
68
+
69
+ **Owns**
70
+
71
+ - The active Astryx locale and optional explicit direction for one React subtree.
72
+ - Binding supplied locale catalogs, rich or generated, and sparse overrides to a
73
+ stable translator function, and exposing them to consumers in the rich shape.
74
+ - Updating provider consumers when any provider input changes.
75
+ - The no-provider English/LTR context defaults exposed by the i18n entry point.
76
+
77
+ **Does not own / non-goals**
78
+
79
+ - Application locale selection, routing, persistence, or account preferences —
80
+ owned by the product.
81
+ - Product-authored strings and application catalogs — owned by product call sites
82
+ or their chosen i18n runtime.
83
+ - DOM `lang` or `dir` attributes — owned by the application document or region.
84
+ - A server/RSC translation context — unresolved separate API work.
85
+ - Rich React-node/function messages — outside the supported string catalog model.
86
+
87
+ ## Public concepts
88
+
89
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
90
+ | --------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------- | ---------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | Locale | Valid BCP 47 locale string | Selects lookup, ICU formatting, and provider-aware locale operations | Every provider subtree | Required on provider; `en` without one | `component:InternationalizationProvider` | stable | Callers must pass a valid tag; malformed tags may throw when formatting or collating, while static key lookup may still reach English |
92
+ | Messages | locale → `Catalog` or `RuntimeCatalog` | Adds translated or application-supplied catalogs, as rich `{defaultMessage, description?}` entries or as a generated key-to-string map, without replacing shipped English | Every locale | `{}` | `component:InternationalizationProvider` | stable | Missing entries fall back through the active locale's parent chain, then to shipped English; a supplied `en` catalog is read only when that chain includes `en`; mixed shapes within one call are accepted |
93
+ | Overrides | locale → sparse key/string map | Rewords individual Astryx strings before catalog lookup | Every locale | none | `component:InternationalizationProvider` | stable | `null` does not become visible content; unresolved keys continue through fallback |
94
+ | Direction | `ltr` or `rtl` | Exposes the provider's semantic direction default | Every provider subtree | derived from locale; `ltr` without provider | `component:InternationalizationProvider` | stable | Invalid values are rejected by the type contract |
95
+
96
+ Consumer prop syntax and examples remain in
97
+ `InternationalizationProvider.doc.mjs` and `astryx docs internationalization`.
98
+
99
+ ## Behavioral and layout contract
100
+
101
+ | ID | Candidate invariant | Basis | Draft review state |
102
+ | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------ |
103
+ | FR1 | The provider MUST resolve Astryx strings through exact-locale overrides, parent-locale overrides, exact/parent supplied catalogs, then shipped English. | Current resolver and focused tests | settled |
104
+ | FR2 | A missing translated key MUST fall back silently; a key absent from every applicable source, including shipped English, MUST warn once in development and render the key visibly. | Released resolver and tests | settled |
105
+ | FR3 | Re-rendering with changed locale, messages, overrides, or direction MUST update the context value and translator used by descendants. | Released provider and rerender tests | settled |
106
+ | FR4 | The provider MUST NOT select, persist, or mutate application locale state. | Released API ownership | settled |
107
+ | FR5 | The provider MUST NOT set DOM `lang` or `dir`. Its direction value and the application's DOM direction are separate inputs the application keeps aligned. | `architecture:internationalization/INV12` | settled |
108
+ | FR6 | Without a provider, the public context and hooks MUST expose locale `en`, direction `ltr`, an empty supplied-catalog map, and a translator backed by shipped English. | Released context and tests | settled |
109
+ | FR7 | The provider MUST preserve a string-returning translator suitable for visible and assistive attributes. | Released catalog/runtime contract | settled |
110
+ | FR8 | A nested provider MUST replace locale, messages, overrides, and direction with values derived solely from the child provider's own props rather than merge parent context. | Released provider and nested-provider tests | settled |
111
+ | FR9 | The provider MUST accept a `RuntimeCatalog` string map or a rich `Catalog` per locale, resolve both to the same strings, and expose `messages` on the context in the rich `MessagesByLocale` shape regardless of input. | `architecture:internationalization/INV15`; provider and resolver runtime-catalog tests | settled |
112
+
113
+ ### Allowed variation
114
+
115
+ - **AV1 — Catalog completeness.** A non-English supplied catalog may contain any
116
+ subset of English keys; missing entries use fallback. Today that fallback
117
+ reaches shipped English only, so it covers Astryx keys; see OQ1.
118
+ - **AV2 — Locale specificity.** Callers may provide a regional, script, or base
119
+ locale. The resolver canonicalizes and walks the corresponding parent chain.
120
+ - **AV3 — Application integration.** Another i18n provider may wrap Astryx inside
121
+ or outside this provider as long as the application keeps locale state aligned.
122
+ - **AV4 — Catalog form.** A caller may pass the shipped rich JSON, the shipped
123
+ generated string map, or a local catalog in either shape, per locale.
124
+
125
+ ### Representative states
126
+
127
+ | State | Required invariant | Allowed variation |
128
+ | --------------------------------- | ------------------ | -------------------------------------------- |
129
+ | no provider | FR6 | none |
130
+ | English provider | FR1–FR4, FR7 | messages and overrides may be omitted |
131
+ | exact translated locale | FR1–FR4, FR7, FR9 | complete or partial catalog, rich or runtime |
132
+ | regional locale with base catalog | FR1–FR4, FR7 | number of parent tags |
133
+ | runtime locale swap | FR3 | application persistence mechanism |
134
+ | explicit direction override | FR3, FR5 | locale may remain LTR for testing |
135
+ | nested provider | FR1–FR8 | child props fully replace parent context |
136
+ | generated runtime catalog input | FR9 | mixed rich and runtime locales |
137
+
138
+ ### Transformation and precedence order
139
+
140
+ - **ORD1 — Message resolution.** Read exact-locale override → walk parent-locale
141
+ overrides → read exact-locale supplied catalog → walk parent supplied catalogs
142
+ → read shipped English → warn and render the key if absent everywhere.
143
+ - **ORD1a — Input normalization.** A `RuntimeCatalog` entry is treated as a rich
144
+ entry with that string as `defaultMessage` and no description before ORD1 runs;
145
+ the context receives the normalized rich map.
146
+ - **ORD2 — Direction resolution.** Use explicit `dir` when supplied; otherwise
147
+ derive direction from locale; outside a provider use `ltr`.
148
+
149
+ ### Performance and resources
150
+
151
+ - **PR1 — Stable translator identity.** A provider render with referentially
152
+ unchanged inputs SHOULD preserve its memoized context value and translator.
153
+ - **PR2 — Parsed message reuse.** ICU formatter instances MAY be cached by locale
154
+ and message while output remains equivalent to fresh formatting.
155
+
156
+ ## Accessibility contract
157
+
158
+ - **AR1 — Assistive strings use the same resolution contract.** `aria-label`,
159
+ `title`, live announcements, and other AT-facing Astryx strings MUST use the
160
+ active translator and remain strings; assembling them from fragments or
161
+ hand-rolled plurals is a bypass under `architecture:internationalization/INV1a`.
162
+ - **AR2 — Missing translations remain usable.** Expected non-English gaps MUST
163
+ fall back to meaningful English rather than remove an accessible name or
164
+ announcement.
165
+ - **AR3 — Direction remains available to browser semantics.** The provider MUST
166
+ document that applications set DOM `dir`; a context value alone does not fix
167
+ browser text flow, punctuation, focus order, or platform bidi behavior.
168
+
169
+ ## Design relationships
170
+
171
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
172
+ | -------------------- | ----------------------------------------------------------------------------- | ----------------------------------- | -------------- | ------------------ |
173
+ | Provider subtree | Product chooses the locale and region direction consistently. | application-owned | supporting | FR3–FR5 |
174
+ | Astryx system string | Astryx supplies a localized default; caller content may specialize semantics. | `architecture:internationalization` | supporting | FR1, FR7, AR1 |
175
+ | Missing translation | Interface remains understandable through English fallback. | `architecture:internationalization` | supporting | FR2, AR2 |
176
+
177
+ ### Theming anatomy
178
+
179
+ <!-- anatomy-theming:v1 -->
180
+
181
+ ```json
182
+ {
183
+ "Provider boundary": {
184
+ "none": {
185
+ "reason": "intentional: InternationalizationProvider renders no DOM element and exposes no theming anatomy."
186
+ }
187
+ }
188
+ }
189
+ ```
190
+
191
+ ## Family and system relationships
192
+
193
+ - `architecture:internationalization` owns cross-library catalog, locale,
194
+ formatter, date-calendar, output-type, external-runtime, and generated
195
+ runtime-catalog delivery boundaries (which files are authored, which are
196
+ derived, how they are published and resolved).
197
+ - Component contracts own which strings are Astryx-authored and which content is
198
+ caller-authored.
199
+
200
+ ## Verification map
201
+
202
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
203
+ | -------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
204
+ | FR1, FR2, ORD1 | `resolve.test.ts` | exact, parent, English, missing source key | Reordering fallback or hiding a missing source key fails focused tests | `audit:InternationalizationProvider/behavior` |
205
+ | FR3, PR1 | `useTranslator.test.tsx`, `useLocale.test.tsx`, `InternationalizationProvider.test.tsx` | stable inputs; locale, message, override, and dir swaps | A stale translator/direction or needless identity churn fails these tests | `audit:InternationalizationProvider/performance` |
206
+ | FR5, ORD2 | `useDirection.test.tsx`, `getLocaleDirection.test.ts` | derived, explicit, invalid locale, no provider | Setting DOM direction or ignoring explicit override fails source/tests | `audit:InternationalizationProvider/rtl` |
207
+ | FR6 | `useTranslator.test.tsx`, `useLocale.test.tsx`, `useDirection.test.tsx` | no provider | Host locale or missing English output fails | `audit:InternationalizationProvider/behavior` |
208
+ | FR7, AR1, AR2 | component translation tests and hardcoded-string lint | visible text, aria label, announcement, missing translation | Non-string output or untranslated owned text fails tests/lint | `audit:InternationalizationProvider/accessibility` |
209
+ | FR8 | `InternationalizationProvider.test.tsx` | parent plus child provider | Parent messages, overrides, or direction leaking into the child fails | `audit:InternationalizationProvider/behavior` |
210
+ | FR9, ORD1a | `useTranslator.test.tsx`, `resolve.test.ts` runtime-catalog cases | generated string map, rich catalog, mixed | A rejected string map, a string-map entry on the context, or divergent output between shapes fails | `audit:InternationalizationProvider/behavior` |
211
+
212
+ ## Decision log
213
+
214
+ ### DEC-1 — Keep the released Astryx runtime narrow
215
+
216
+ **Reference:** `component:InternationalizationProvider/DEC-1`
217
+ **Decider:** `nynexman4464`, `2026-09-03`
218
+
219
+ Astryx keeps `InternationalizationProvider` and the released i18n hooks as its
220
+ canonical runtime. Astryx may support small applications, but it does not absorb
221
+ application-owned rich messages and functions to compete with full i18n
222
+ frameworks. Applications with larger needs use an established application i18n
223
+ runtime alongside Astryx; an adapter remains possible as additive follow-up.
224
+
225
+ Rejected: a parallel provider/hook runtime that replaces the released surface
226
+ and mixes Astryx string ownership with application rich-message ownership.
227
+
228
+ ## Open questions
229
+
230
+ - **OQ1 — Supplied `en` as a fallback for non-Astryx keys (known bug).** The
231
+ shipped English runtime catalog is the only cross-locale fallback and it holds
232
+ Astryx keys only. An application or library key that is missing from the active
233
+ locale therefore renders as its raw key even when the caller supplied an `en`
234
+ catalog. This is a known cross-locale fallback defect. The contract describes
235
+ the shipped behavior without endorsing the limitation; a supplied-English
236
+ fallback requires a corresponding FR1/ORD1 amendment and a regression test
237
+ using a partially translated non-Astryx key. (`human-api`)
238
+ - **OQ2 — Malformed locale behavior.** Decide whether a future release should
239
+ reject invalid BCP 47 tags at the provider boundary or preserve today's
240
+ formatter-specific throws. (`human-api`)
241
+ - **OQ3 — External runtime adapter.** Decide the exact additive `Translator`
242
+ adapter contract, including who owns lookup, fallback, and formatter failures.
243
+ (`human-api`)
244
+ - **OQ4 — Server translation runtime.** Decide whether the small set of otherwise
245
+ server-safe consumers justifies a separate RSC translation API and request
246
+ isolation model. (`human-api`)
247
+
248
+ ## Content boundary
249
+
250
+ This file does not duplicate consumer prop tables/examples, catalog validation
251
+ implementation, runtime-catalog generation, current translation coverage, or
252
+ cross-library i18n rules. It links to their owners.
@@ -0,0 +1,128 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file InternationalizationProvider.test.tsx
5
+ * @input packages/core/src/i18n/InternationalizationProvider.tsx, useTranslator,
6
+ * useDirection, useLocale
7
+ * @output Unit tests for the provider's update and nesting contract
8
+ * @position Colocated tests; targets re-rendering when messages, overrides, or
9
+ * dir change (locale is covered by useLocale/useTranslator tests) and a nested
10
+ * provider replacing rather than merging its parent's context.
11
+ */
12
+
13
+ import {describe, expect, test} from 'vitest';
14
+ import {render} from '@testing-library/react';
15
+ import {InternationalizationProvider} from '../InternationalizationProvider';
16
+ import {useDirection} from '../useDirection';
17
+ import {useLocale} from '../useLocale';
18
+ import {useTranslator} from '../useTranslator';
19
+ import type {MessagesByLocale, Overrides} from '../types';
20
+
21
+ const NEXT = '@astryx.pagination.next';
22
+ const APP_KEY = '@myapp.save';
23
+
24
+ function Probe() {
25
+ const t = useTranslator();
26
+ return (
27
+ <>
28
+ <span data-testid="next">{t(NEXT)}</span>
29
+ <span data-testid="app">{t(APP_KEY)}</span>
30
+ <span data-testid="locale">{useLocale()}</span>
31
+ <span data-testid="dir">{useDirection()}</span>
32
+ </>
33
+ );
34
+ }
35
+
36
+ const FR_MESSAGES: MessagesByLocale = {
37
+ fr: {[APP_KEY]: {defaultMessage: 'Enregistrer'}},
38
+ };
39
+ const FR_MESSAGES_V2: MessagesByLocale = {
40
+ fr: {[APP_KEY]: {defaultMessage: 'Sauvegarder'}},
41
+ };
42
+ const FR_OVERRIDES: Overrides = {fr: {[NEXT]: 'Suivant'}};
43
+ const FR_OVERRIDES_V2: Overrides = {fr: {[NEXT]: 'Page suivante'}};
44
+
45
+ describe('InternationalizationProvider — re-rendering', () => {
46
+ test('re-renders consumers when messages change', () => {
47
+ const {getByTestId, rerender} = render(
48
+ <InternationalizationProvider locale="fr" messages={FR_MESSAGES}>
49
+ <Probe />
50
+ </InternationalizationProvider>,
51
+ );
52
+ expect(getByTestId('app').textContent).toBe('Enregistrer');
53
+
54
+ rerender(
55
+ <InternationalizationProvider locale="fr" messages={FR_MESSAGES_V2}>
56
+ <Probe />
57
+ </InternationalizationProvider>,
58
+ );
59
+ expect(getByTestId('app').textContent).toBe('Sauvegarder');
60
+ });
61
+
62
+ test('re-renders consumers when overrides change', () => {
63
+ const {getByTestId, rerender} = render(
64
+ <InternationalizationProvider locale="fr" overrides={FR_OVERRIDES}>
65
+ <Probe />
66
+ </InternationalizationProvider>,
67
+ );
68
+ expect(getByTestId('next').textContent).toBe('Suivant');
69
+
70
+ rerender(
71
+ <InternationalizationProvider locale="fr" overrides={FR_OVERRIDES_V2}>
72
+ <Probe />
73
+ </InternationalizationProvider>,
74
+ );
75
+ expect(getByTestId('next').textContent).toBe('Page suivante');
76
+ });
77
+
78
+ test('re-renders consumers when dir changes', () => {
79
+ const {getByTestId, rerender} = render(
80
+ <InternationalizationProvider locale="en" dir="rtl">
81
+ <Probe />
82
+ </InternationalizationProvider>,
83
+ );
84
+ expect(getByTestId('dir').textContent).toBe('rtl');
85
+
86
+ rerender(
87
+ <InternationalizationProvider locale="en" dir="ltr">
88
+ <Probe />
89
+ </InternationalizationProvider>,
90
+ );
91
+ expect(getByTestId('dir').textContent).toBe('ltr');
92
+ });
93
+ });
94
+
95
+ describe('InternationalizationProvider — nesting', () => {
96
+ test('a nested provider replaces the parent context instead of merging it', () => {
97
+ const {getByTestId} = render(
98
+ <InternationalizationProvider
99
+ locale="fr"
100
+ dir="rtl"
101
+ messages={FR_MESSAGES}
102
+ overrides={FR_OVERRIDES}>
103
+ <InternationalizationProvider locale="fr">
104
+ <Probe />
105
+ </InternationalizationProvider>
106
+ </InternationalizationProvider>,
107
+ );
108
+ // Locale is the child's own; direction derives from it, not the parent's
109
+ // explicit rtl; parent overrides and catalogs do not leak into the child.
110
+ expect(getByTestId('locale').textContent).toBe('fr');
111
+ expect(getByTestId('dir').textContent).toBe('ltr');
112
+ expect(getByTestId('next').textContent).toBe('Go to next page');
113
+ expect(getByTestId('app').textContent).toBe(APP_KEY);
114
+ });
115
+
116
+ test('a nested provider can narrow the locale for one subtree', () => {
117
+ const {getByTestId} = render(
118
+ <InternationalizationProvider locale="fr" overrides={FR_OVERRIDES}>
119
+ <InternationalizationProvider locale="de">
120
+ <Probe />
121
+ </InternationalizationProvider>
122
+ </InternationalizationProvider>,
123
+ );
124
+ expect(getByTestId('locale').textContent).toBe('de');
125
+ // No de override or catalog: shipped English wins.
126
+ expect(getByTestId('next').textContent).toBe('Go to next page');
127
+ });
128
+ });
@@ -158,6 +158,27 @@ const runtimeCatalog: Readonly<Record<string, string>> = {
158
158
  '{itemName} dropped at position {position, number} of {total, number}.',
159
159
  '@astryx.listInput.announceAlreadyAtBoundary':
160
160
  'This {itemName} is already {boundary, select, first {first} last {last} other {}}.',
161
+ '@astryx.diffViewer.label': 'Diff',
162
+ '@astryx.diffViewer.renamedPath': '{oldPath} → {newPath}',
163
+ '@astryx.diffViewer.statusAdded': 'Added',
164
+ '@astryx.diffViewer.statusDeleted': 'Deleted',
165
+ '@astryx.diffViewer.statusRenamed': 'Renamed',
166
+ '@astryx.diffViewer.addedCount': '+{count, number}',
167
+ '@astryx.diffViewer.removedCount': '−{count, number}',
168
+ '@astryx.diffViewer.stats':
169
+ '{added, plural, one {# line added} other {# lines added}}, {removed, plural, one {# line removed} other {# lines removed}}',
170
+ '@astryx.diffViewer.added': 'Added',
171
+ '@astryx.diffViewer.removed': 'Removed',
172
+ '@astryx.diffViewer.lineNumberButton':
173
+ '{side, select, old {Original line {line, number}} other {Changed line {line, number}}}',
174
+ '@astryx.diffViewer.columnOldLine': 'Original line number',
175
+ '@astryx.diffViewer.columnNewLine': 'Changed line number',
176
+ '@astryx.diffViewer.columnChange': 'Change',
177
+ '@astryx.diffViewer.columnCode': 'Code',
178
+ '@astryx.diffViewer.columnOldCode': 'Original code',
179
+ '@astryx.diffViewer.columnNewCode': 'Changed code',
180
+ '@astryx.diffViewer.binary': 'Binary file not shown',
181
+ '@astryx.diffViewer.noChanges': 'No content changes',
161
182
  '@astryx.markdownHeadingLinks.copied': 'Link copied',
162
183
  '@astryx.markdownHeadingLinks.copy': 'Copy link to {heading}',
163
184
  '@astryx.richTextEditor.tabEscapeHint':
@@ -163,6 +163,27 @@ const runtimeCatalog: Readonly<Record<string, string>> = {
163
163
  '⟦{itemName} ðřóþþéð àţ þóšíţíóñ {position, number} óƒ {total, number}.⟧',
164
164
  '@astryx.listInput.announceAlreadyAtBoundary':
165
165
  '⟦Ţĥíš {itemName} íš àłřéàðý {boundary, select, first {first} last {last} other {}}.⟧',
166
+ '@astryx.diffViewer.label': '⟦Ð탃⟧',
167
+ '@astryx.diffViewer.renamedPath': '⟦{oldPath} → {newPath}⟧',
168
+ '@astryx.diffViewer.statusAdded': '⟦Àððéð⟧',
169
+ '@astryx.diffViewer.statusDeleted': '⟦Ðéłéţéð⟧',
170
+ '@astryx.diffViewer.statusRenamed': '⟦Řéñàɱéð⟧',
171
+ '@astryx.diffViewer.addedCount': '⟦+{count, number}⟧',
172
+ '@astryx.diffViewer.removedCount': '⟦−{count, number}⟧',
173
+ '@astryx.diffViewer.stats':
174
+ '⟦{added, plural, one {# line added} other {# lines added}}, {removed, plural, one {# line removed} other {# lines removed}}⟧',
175
+ '@astryx.diffViewer.added': '⟦Àððéð⟧',
176
+ '@astryx.diffViewer.removed': '⟦Řéɱóṽéð⟧',
177
+ '@astryx.diffViewer.lineNumberButton':
178
+ '⟦{side, select, old {Original line {line, number}} other {Changed line {line, number}}}⟧',
179
+ '@astryx.diffViewer.columnOldLine': '⟦Óříĝíñàł łíñé ñúɱƀéř⟧',
180
+ '@astryx.diffViewer.columnNewLine': '⟦Çĥàñĝéð łíñé ñúɱƀéř⟧',
181
+ '@astryx.diffViewer.columnChange': '⟦Çĥàñĝé⟧',
182
+ '@astryx.diffViewer.columnCode': '⟦Çóðé⟧',
183
+ '@astryx.diffViewer.columnOldCode': '⟦Óříĝíñàł çóðé⟧',
184
+ '@astryx.diffViewer.columnNewCode': '⟦Çĥàñĝéð çóðé⟧',
185
+ '@astryx.diffViewer.binary': '⟦Ɓíñàřý ƒíłé ñóţ šĥóŵñ⟧',
186
+ '@astryx.diffViewer.noChanges': '⟦Ñó çóñţéñţ çĥàñĝéš⟧',
166
187
  '@astryx.markdownHeadingLinks.copied': '⟦Łíñķ çóþíéð⟧',
167
188
  '@astryx.markdownHeadingLinks.copy': '⟦Çóþý łíñķ ţó {heading}⟧',
168
189
  '@astryx.richTextEditor.tabEscapeHint':