@astryxdesign/core 0.7.1 → 0.7.2-canary.b06a093
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/PowerSearch/PowerSearch.js +1 -1
- 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/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +26 -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/PowerSearch/PowerSearch.test.tsx +85 -0
- package/src/PowerSearch/PowerSearch.tsx +2 -2
- package/src/Switch/Switch.test.tsx +28 -0
- package/src/Switch/Switch.tsx +6 -0
- package/src/Tokenizer/Tokenizer.test.tsx +54 -1
- package/src/Tooltip/useTooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +11 -0
- package/src/Typeahead/BaseTypeahead.tsx +26 -0
- package/src/Typeahead/Typeahead.test.tsx +260 -2
- 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
|
@@ -17,10 +17,20 @@ import {
|
|
|
17
17
|
beforeAll,
|
|
18
18
|
afterAll,
|
|
19
19
|
beforeEach,
|
|
20
|
+
afterEach,
|
|
20
21
|
} from 'vitest';
|
|
21
22
|
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
22
23
|
import userEvent from '@testing-library/user-event';
|
|
23
|
-
import {
|
|
24
|
+
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
25
|
+
import {
|
|
26
|
+
createRef,
|
|
27
|
+
Profiler,
|
|
28
|
+
startTransition,
|
|
29
|
+
StrictMode,
|
|
30
|
+
Suspense,
|
|
31
|
+
useState,
|
|
32
|
+
type ProfilerOnRenderCallback,
|
|
33
|
+
} from 'react';
|
|
24
34
|
import {Typeahead} from './Typeahead';
|
|
25
35
|
import {readFile} from 'node:fs/promises';
|
|
26
36
|
import {resolve} from 'node:path';
|
|
@@ -70,6 +80,10 @@ afterAll(() => {
|
|
|
70
80
|
(HTMLElement.prototype as any).matches = originalMatches;
|
|
71
81
|
});
|
|
72
82
|
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
__resetLiveRegionsForTest();
|
|
85
|
+
});
|
|
86
|
+
|
|
73
87
|
// Test data
|
|
74
88
|
const fruits: SearchableItem[] = [
|
|
75
89
|
{id: '1', label: 'Apple'},
|
|
@@ -964,6 +978,193 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
964
978
|
).not.toBeInTheDocument();
|
|
965
979
|
});
|
|
966
980
|
|
|
981
|
+
it('clears the loading state when the search source is replaced during an in-flight load', async () => {
|
|
982
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
983
|
+
const bootstrap = vi.fn(
|
|
984
|
+
async () =>
|
|
985
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
986
|
+
settle = resolve;
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
const initialSource: SearchSource = {search: () => [], bootstrap};
|
|
990
|
+
const replacementSource: SearchSource = {
|
|
991
|
+
search: () => [],
|
|
992
|
+
bootstrap: () => [],
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const {rerender} = render(
|
|
996
|
+
<BaseTypeahead
|
|
997
|
+
searchSource={initialSource}
|
|
998
|
+
value={null}
|
|
999
|
+
onChange={() => {}}
|
|
1000
|
+
hasEntriesOnFocus
|
|
1001
|
+
/>,
|
|
1002
|
+
);
|
|
1003
|
+
const input = screen.getByRole('combobox');
|
|
1004
|
+
|
|
1005
|
+
fireEvent.focus(input);
|
|
1006
|
+
await waitFor(() => {
|
|
1007
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
rerender(
|
|
1011
|
+
<BaseTypeahead
|
|
1012
|
+
searchSource={replacementSource}
|
|
1013
|
+
value={null}
|
|
1014
|
+
onChange={() => {}}
|
|
1015
|
+
hasEntriesOnFocus
|
|
1016
|
+
/>,
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
await waitFor(() => {
|
|
1020
|
+
expect(
|
|
1021
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1022
|
+
).not.toBeInTheDocument();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
await act(async () => {
|
|
1026
|
+
settle([]);
|
|
1027
|
+
await Promise.resolve();
|
|
1028
|
+
});
|
|
1029
|
+
expect(
|
|
1030
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1031
|
+
).not.toBeInTheDocument();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('keeps the loading state under StrictMode for a pending autofocus bootstrap', async () => {
|
|
1035
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
1036
|
+
const bootstrap = vi.fn(
|
|
1037
|
+
async () =>
|
|
1038
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1039
|
+
settle = resolve;
|
|
1040
|
+
}),
|
|
1041
|
+
);
|
|
1042
|
+
render(
|
|
1043
|
+
<StrictMode>
|
|
1044
|
+
<Typeahead
|
|
1045
|
+
label="Test"
|
|
1046
|
+
searchSource={{search: () => [], bootstrap}}
|
|
1047
|
+
value={null}
|
|
1048
|
+
onChange={() => {}}
|
|
1049
|
+
hasAutoFocus
|
|
1050
|
+
hasEntriesOnFocus
|
|
1051
|
+
/>
|
|
1052
|
+
</StrictMode>,
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1056
|
+
|
|
1057
|
+
await act(async () => {
|
|
1058
|
+
settle([]);
|
|
1059
|
+
await Promise.resolve();
|
|
1060
|
+
});
|
|
1061
|
+
expect(
|
|
1062
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1063
|
+
).not.toBeInTheDocument();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
let settlePendingTransitionA: (items: SearchableItem[]) => void = () => {};
|
|
1067
|
+
const stableTransitionSourceA: SearchSource = {
|
|
1068
|
+
search: vi.fn(
|
|
1069
|
+
async () =>
|
|
1070
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1071
|
+
settlePendingTransitionA = resolve;
|
|
1072
|
+
}),
|
|
1073
|
+
),
|
|
1074
|
+
bootstrap: () => [],
|
|
1075
|
+
};
|
|
1076
|
+
const stableTransitionSourceB: SearchSource = {
|
|
1077
|
+
search: vi.fn(async () => []),
|
|
1078
|
+
bootstrap: () => [],
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
it('clears loading and aria-busy when source is replaced in a transition that suspends and retries', async () => {
|
|
1082
|
+
let isSuspended = false;
|
|
1083
|
+
let resolveSuspension: () => void = () => {};
|
|
1084
|
+
let pendingSuspension: Promise<void> | null = null;
|
|
1085
|
+
|
|
1086
|
+
function SuspendingSibling({shouldSuspend}: {shouldSuspend: boolean}) {
|
|
1087
|
+
if (shouldSuspend && isSuspended && pendingSuspension) {
|
|
1088
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
1089
|
+
throw pendingSuspension;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let updateState!: (source: SearchSource, shouldSuspend: boolean) => void;
|
|
1095
|
+
|
|
1096
|
+
function TransitionContainer() {
|
|
1097
|
+
const [source, setSource] = useState<SearchSource>(
|
|
1098
|
+
stableTransitionSourceA,
|
|
1099
|
+
);
|
|
1100
|
+
const [shouldSuspend, setShouldSuspend] = useState(false);
|
|
1101
|
+
updateState = (newSource, suspend) => {
|
|
1102
|
+
setSource(newSource);
|
|
1103
|
+
setShouldSuspend(suspend);
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
return (
|
|
1107
|
+
<Suspense fallback={<div>Suspended...</div>}>
|
|
1108
|
+
<Typeahead
|
|
1109
|
+
label="Test"
|
|
1110
|
+
searchSource={source}
|
|
1111
|
+
value={null}
|
|
1112
|
+
onChange={() => {}}
|
|
1113
|
+
debounceMs={0}
|
|
1114
|
+
/>
|
|
1115
|
+
<SuspendingSibling shouldSuspend={shouldSuspend} />
|
|
1116
|
+
</Suspense>
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
render(<TransitionContainer />);
|
|
1121
|
+
const input = screen.getByRole('combobox');
|
|
1122
|
+
|
|
1123
|
+
// Trigger a load so aria-busy="true" is set on the combobox
|
|
1124
|
+
fireEvent.change(input, {target: {value: 'query'}});
|
|
1125
|
+
await waitFor(() => {
|
|
1126
|
+
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
1127
|
+
});
|
|
1128
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1129
|
+
|
|
1130
|
+
// Create the pending promise for suspension
|
|
1131
|
+
isSuspended = true;
|
|
1132
|
+
pendingSuspension = new Promise<void>(resolve => {
|
|
1133
|
+
resolveSuspension = () => {
|
|
1134
|
+
isSuspended = false;
|
|
1135
|
+
resolve();
|
|
1136
|
+
};
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// Inside startTransition, switch from source A to source B while sibling suspends
|
|
1140
|
+
act(() => {
|
|
1141
|
+
startTransition(() => {
|
|
1142
|
+
updateState(stableTransitionSourceB, true);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// The render suspended, so the previous committed state remains
|
|
1147
|
+
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
1148
|
+
|
|
1149
|
+
// Resolve the pending promise and let the transition commit
|
|
1150
|
+
await act(async () => {
|
|
1151
|
+
resolveSuspension();
|
|
1152
|
+
await pendingSuspension;
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Settle the abandoned load
|
|
1156
|
+
await act(async () => {
|
|
1157
|
+
settlePendingTransitionA([]);
|
|
1158
|
+
await Promise.resolve();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// The combobox must not keep aria-busy and the spinner must be gone
|
|
1162
|
+
expect(input).not.toHaveAttribute('aria-busy');
|
|
1163
|
+
expect(
|
|
1164
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1165
|
+
).not.toBeInTheDocument();
|
|
1166
|
+
});
|
|
1167
|
+
|
|
967
1168
|
it('shows bootstrap results on mouse click', async () => {
|
|
968
1169
|
render(
|
|
969
1170
|
<BaseTypeahead
|
|
@@ -1323,6 +1524,63 @@ describe('BaseTypeahead hasSearched reset', () => {
|
|
|
1323
1524
|
// "No results found" should disappear since hasSearched is reset
|
|
1324
1525
|
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
|
|
1325
1526
|
});
|
|
1527
|
+
|
|
1528
|
+
it('resets hasSearched and does not announce "No results found" when search source is replaced during a pending search', async () => {
|
|
1529
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
1530
|
+
const initialSource: SearchSource = {
|
|
1531
|
+
search: vi.fn(
|
|
1532
|
+
async () =>
|
|
1533
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1534
|
+
settle = resolve;
|
|
1535
|
+
}),
|
|
1536
|
+
),
|
|
1537
|
+
bootstrap: () => [],
|
|
1538
|
+
};
|
|
1539
|
+
const replacementSource: SearchSource = {
|
|
1540
|
+
search: vi.fn(async () => []),
|
|
1541
|
+
bootstrap: () => [],
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
const {rerender} = render(
|
|
1545
|
+
<BaseTypeahead
|
|
1546
|
+
searchSource={initialSource}
|
|
1547
|
+
value={null}
|
|
1548
|
+
onChange={() => {}}
|
|
1549
|
+
debounceMs={0}
|
|
1550
|
+
/>,
|
|
1551
|
+
);
|
|
1552
|
+
const input = screen.getByRole('combobox');
|
|
1553
|
+
|
|
1554
|
+
// Type a query that starts an asynchronous search
|
|
1555
|
+
fireEvent.change(input, {target: {value: 'abc'}});
|
|
1556
|
+
expect(initialSource.search).toHaveBeenCalledWith('abc');
|
|
1557
|
+
|
|
1558
|
+
// Replace the search source while the search is still pending
|
|
1559
|
+
rerender(
|
|
1560
|
+
<BaseTypeahead
|
|
1561
|
+
searchSource={replacementSource}
|
|
1562
|
+
value={null}
|
|
1563
|
+
onChange={() => {}}
|
|
1564
|
+
debounceMs={0}
|
|
1565
|
+
/>,
|
|
1566
|
+
);
|
|
1567
|
+
|
|
1568
|
+
// Settle the abandoned search
|
|
1569
|
+
await act(async () => {
|
|
1570
|
+
settle([]);
|
|
1571
|
+
await Promise.resolve();
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
await act(
|
|
1575
|
+
async () =>
|
|
1576
|
+
new Promise<void>(resolve => requestAnimationFrame(() => resolve())),
|
|
1577
|
+
);
|
|
1578
|
+
expect(
|
|
1579
|
+
document.querySelector('[data-astryx-live-region="polite"]')
|
|
1580
|
+
?.textContent ?? '',
|
|
1581
|
+
).not.toContain('No results found');
|
|
1582
|
+
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
|
|
1583
|
+
});
|
|
1326
1584
|
});
|
|
1327
1585
|
|
|
1328
1586
|
describe('BaseTypeahead popover after selection', () => {
|
|
@@ -2313,7 +2571,7 @@ describe('Typeahead consumer key handler order', () => {
|
|
|
2313
2571
|
currentTarget = event.currentTarget;
|
|
2314
2572
|
event.preventDefault();
|
|
2315
2573
|
});
|
|
2316
|
-
|
|
2574
|
+
render(
|
|
2317
2575
|
<Typeahead
|
|
2318
2576
|
label="Fruit"
|
|
2319
2577
|
searchSource={fruitSource}
|
|
@@ -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.
|