@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.
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/MultiSelector/hooks.d.ts.map +1 -1
- package/dist/MultiSelector/hooks.js +3 -1
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +6 -0
- package/dist/astryx.css +1 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +19 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
- package/locales/en.json +76 -0
- package/locales/pseudo.json +57 -0
- package/package.json +4 -4
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/MultiSelector/hooks.ts +3 -1
- package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/Switch/Switch.test.tsx +28 -0
- package/src/Switch/Switch.tsx +6 -0
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +21 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
- 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
|
|
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.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
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', () => {
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -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':
|