@object-ui/i18n 3.1.5 → 3.3.1

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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +21 -1
  3. package/dist/i18n.js +10 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +2 -0
  6. package/dist/locales/ar.d.ts +260 -1
  7. package/dist/locales/ar.js +260 -1
  8. package/dist/locales/de.d.ts +260 -1
  9. package/dist/locales/de.js +269 -1
  10. package/dist/locales/en.d.ts +279 -0
  11. package/dist/locales/en.js +289 -0
  12. package/dist/locales/es.d.ts +260 -1
  13. package/dist/locales/es.js +260 -1
  14. package/dist/locales/fr.d.ts +260 -1
  15. package/dist/locales/fr.js +269 -1
  16. package/dist/locales/index.d.ts +2661 -9
  17. package/dist/locales/ja.d.ts +271 -1
  18. package/dist/locales/ja.js +271 -1
  19. package/dist/locales/ko.d.ts +260 -1
  20. package/dist/locales/ko.js +260 -1
  21. package/dist/locales/pt.d.ts +260 -1
  22. package/dist/locales/pt.js +260 -1
  23. package/dist/locales/ru.d.ts +271 -1
  24. package/dist/locales/ru.js +280 -1
  25. package/dist/locales/zh.d.ts +280 -1
  26. package/dist/locales/zh.js +290 -1
  27. package/dist/useObjectLabel.d.ts +46 -0
  28. package/dist/useObjectLabel.js +79 -12
  29. package/dist/useSafeTranslation.d.ts +5 -0
  30. package/dist/useSafeTranslation.js +46 -0
  31. package/package.json +31 -8
  32. package/.turbo/turbo-build.log +0 -4
  33. package/src/__tests__/i18n.test.ts +0 -228
  34. package/src/__tests__/provider.test.tsx +0 -117
  35. package/src/__tests__/spec-formatters.test.ts +0 -208
  36. package/src/__tests__/useObjectLabel.test.tsx +0 -131
  37. package/src/i18n.ts +0 -98
  38. package/src/index.ts +0 -78
  39. package/src/locales/ar.ts +0 -541
  40. package/src/locales/de.ts +0 -545
  41. package/src/locales/en.ts +0 -625
  42. package/src/locales/es.ts +0 -540
  43. package/src/locales/fr.ts +0 -545
  44. package/src/locales/index.ts +0 -42
  45. package/src/locales/ja.ts +0 -540
  46. package/src/locales/ko.ts +0 -540
  47. package/src/locales/pt.ts +0 -540
  48. package/src/locales/ru.ts +0 -540
  49. package/src/locales/zh.ts +0 -624
  50. package/src/provider.tsx +0 -177
  51. package/src/useObjectLabel.ts +0 -122
  52. package/src/utils/formatting.ts +0 -136
  53. package/src/utils/index.ts +0 -21
  54. package/src/utils/spec-formatters.ts +0 -220
  55. package/tsconfig.json +0 -12
  56. package/tsconfig.tsbuildinfo +0 -1
@@ -50,6 +50,43 @@ export declare function useObjectLabel(): {
50
50
  * Resolve translated field label, falling back to the provided fallback string.
51
51
  */
52
52
  fieldLabel: (objectName: string, fieldName: string, fallback: string) => string;
53
+ /**
54
+ * Resolve a translated select option label for a given object field.
55
+ * Falls back to the provided fallback (usually the English option label).
56
+ */
57
+ fieldOptionLabel: (objectName: string, fieldName: string, optionValue: string, fallback: string) => string;
58
+ /**
59
+ * Translate all options for a given field, returning a new options array
60
+ * with translated labels. Pass the objectName and fieldName to look up
61
+ * translations; the original label is used as fallback.
62
+ */
63
+ translateOptions: (objectName: string, fieldName: string, options: Array<{
64
+ value: string;
65
+ label: string;
66
+ [key: string]: any;
67
+ }>) => Array<{
68
+ value: string;
69
+ label: string;
70
+ [key: string]: any;
71
+ }>;
72
+ /**
73
+ * Resolve translated app label, falling back to appDef.label.
74
+ * Looks up `{ns}.apps.{appName}.label` from loaded i18next resources.
75
+ */
76
+ appLabel: (appDef: {
77
+ name: string;
78
+ label?: string;
79
+ }) => string;
80
+ /**
81
+ * Resolve translated app description, falling back to appDef.description.
82
+ * Returns the translated value even when metadata has no description —
83
+ * translation-only descriptions (defined only in i18n bundles) are common
84
+ * in examples where the app metadata is English-only.
85
+ */
86
+ appDescription: (appDef: {
87
+ name: string;
88
+ description?: string;
89
+ }) => string | undefined;
53
90
  };
54
91
  /**
55
92
  * Safe wrapper for useObjectLabel that falls back to identity functions
@@ -58,4 +95,13 @@ export declare function useObjectLabel(): {
58
95
  */
59
96
  export declare function useSafeFieldLabel(): {
60
97
  fieldLabel: (objectName: string, fieldName: string, fallback: string) => string;
98
+ translateOptions: (objectName: string, fieldName: string, options: Array<{
99
+ value: string;
100
+ label: string;
101
+ [key: string]: any;
102
+ }>) => Array<{
103
+ value: string;
104
+ label: string;
105
+ [key: string]: any;
106
+ }>;
61
107
  };
@@ -48,7 +48,8 @@ export function useObjectLabel() {
48
48
  /**
49
49
  * Discover app namespace(s) from loaded i18next resources.
50
50
  * Returns top-level keys (outside built-in Object UI keys) that contain
51
- * an `objects` or `fields` sub-key — e.g. "crm" when resources include crm.objects.*.
51
+ * an `objects`, `fields`, or `apps` sub-key — e.g. "crm" when resources
52
+ * include crm.objects.* or crm.apps.*.
52
53
  */
53
54
  const getAppNamespaces = () => {
54
55
  if (!i18n || typeof i18n.getResourceBundle !== 'function')
@@ -58,17 +59,30 @@ export function useObjectLabel() {
58
59
  if (!bundle)
59
60
  return [];
60
61
  return Object.keys(bundle).filter((key) => !BUILTIN_KEYS.has(key) && bundle[key] && typeof bundle[key] === 'object'
61
- && (bundle[key].objects || bundle[key].fields));
62
+ && (bundle[key].objects || bundle[key].fields || bundle[key].apps));
63
+ };
64
+ /**
65
+ * Strip an ObjectStack namespace prefix (e.g. `crm__lead` → `lead`) so that
66
+ * translations authored against short object names still resolve when the
67
+ * runtime presents fully-qualified names. The first `__` separates the
68
+ * package namespace from the base name; everything after is preserved.
69
+ */
70
+ const stripNamespace = (name) => {
71
+ const idx = name.indexOf('__');
72
+ return idx > 0 ? name.slice(idx + 2) : name;
62
73
  };
63
74
  /** Try resolving a key across all discovered app namespaces. */
64
- const resolve = (suffix, fallback) => {
75
+ const resolve = (suffixes, fallback) => {
76
+ const suffixList = Array.isArray(suffixes) ? suffixes : [suffixes];
65
77
  try {
66
78
  const namespaces = getAppNamespaces();
67
79
  for (const ns of namespaces) {
68
- const key = `${ns}.${suffix}`;
69
- const translated = t(key, { defaultValue: '' });
70
- if (translated && translated !== key && translated !== '') {
71
- return translated;
80
+ for (const suffix of suffixList) {
81
+ const key = `${ns}.${suffix}`;
82
+ const translated = t(key, { defaultValue: '' });
83
+ if (translated && translated !== key && translated !== '') {
84
+ return translated;
85
+ }
72
86
  }
73
87
  }
74
88
  }
@@ -77,23 +91,75 @@ export function useObjectLabel() {
77
91
  }
78
92
  return fallback;
79
93
  };
94
+ /** Build suffix candidates: prefer the given name, fall back to the base (unprefixed) name. */
95
+ const objectSuffixes = (objectName, tail) => {
96
+ const base = stripNamespace(objectName);
97
+ return base !== objectName
98
+ ? [`objects.${objectName}.${tail}`, `objects.${base}.${tail}`]
99
+ : [`objects.${objectName}.${tail}`];
100
+ };
101
+ const fieldSuffixes = (objectName, fieldName) => {
102
+ const base = stripNamespace(objectName);
103
+ return base !== objectName
104
+ ? [`fields.${objectName}.${fieldName}`, `fields.${base}.${fieldName}`]
105
+ : [`fields.${objectName}.${fieldName}`];
106
+ };
107
+ const optionSuffixes = (objectName, fieldName, optionValue) => {
108
+ const base = stripNamespace(objectName);
109
+ return base !== objectName
110
+ ? [
111
+ `fieldOptions.${objectName}.${fieldName}.${optionValue}`,
112
+ `fieldOptions.${base}.${fieldName}.${optionValue}`,
113
+ ]
114
+ : [`fieldOptions.${objectName}.${fieldName}.${optionValue}`];
115
+ };
80
116
  return {
81
117
  /**
82
118
  * Resolve translated object label, falling back to objectDef.label.
83
119
  */
84
- objectLabel: (objectDef) => resolve(`objects.${objectDef.name}.label`, objectDef.label),
120
+ objectLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label),
85
121
  /**
86
122
  * Resolve translated object description, falling back to objectDef.description.
87
123
  */
88
124
  objectDescription: (objectDef) => {
89
125
  if (!objectDef.description)
90
126
  return undefined;
91
- return resolve(`objects.${objectDef.name}.description`, objectDef.description);
127
+ return resolve(objectSuffixes(objectDef.name, 'description'), objectDef.description);
92
128
  },
93
129
  /**
94
130
  * Resolve translated field label, falling back to the provided fallback string.
95
131
  */
96
- fieldLabel: (objectName, fieldName, fallback) => resolve(`fields.${objectName}.${fieldName}`, fallback),
132
+ fieldLabel: (objectName, fieldName, fallback) => resolve(fieldSuffixes(objectName, fieldName), fallback),
133
+ /**
134
+ * Resolve a translated select option label for a given object field.
135
+ * Falls back to the provided fallback (usually the English option label).
136
+ */
137
+ fieldOptionLabel: (objectName, fieldName, optionValue, fallback) => resolve(optionSuffixes(objectName, fieldName, optionValue), fallback),
138
+ /**
139
+ * Translate all options for a given field, returning a new options array
140
+ * with translated labels. Pass the objectName and fieldName to look up
141
+ * translations; the original label is used as fallback.
142
+ */
143
+ translateOptions: (objectName, fieldName, options) => options.map(opt => ({
144
+ ...opt,
145
+ label: resolve(optionSuffixes(objectName, fieldName, opt.value), opt.label),
146
+ })),
147
+ /**
148
+ * Resolve translated app label, falling back to appDef.label.
149
+ * Looks up `{ns}.apps.{appName}.label` from loaded i18next resources.
150
+ */
151
+ appLabel: (appDef) => resolve(`apps.${appDef.name}.label`, appDef.label ?? appDef.name),
152
+ /**
153
+ * Resolve translated app description, falling back to appDef.description.
154
+ * Returns the translated value even when metadata has no description —
155
+ * translation-only descriptions (defined only in i18n bundles) are common
156
+ * in examples where the app metadata is English-only.
157
+ */
158
+ appDescription: (appDef) => {
159
+ const fallback = appDef.description ?? '';
160
+ const resolved = resolve(`apps.${appDef.name}.description`, fallback);
161
+ return resolved || undefined;
162
+ },
97
163
  };
98
164
  }
99
165
  /**
@@ -103,12 +169,13 @@ export function useObjectLabel() {
103
169
  */
104
170
  export function useSafeFieldLabel() {
105
171
  try {
106
- const { fieldLabel } = useObjectLabel();
107
- return { fieldLabel };
172
+ const { fieldLabel, translateOptions } = useObjectLabel();
173
+ return { fieldLabel, translateOptions };
108
174
  }
109
175
  catch {
110
176
  return {
111
177
  fieldLabel: (_objectName, _fieldName, fallback) => fallback,
178
+ translateOptions: (_objectName, _fieldName, options) => options,
112
179
  };
113
180
  }
114
181
  }
@@ -0,0 +1,5 @@
1
+ export declare function createSafeTranslation(defaults: Record<string, string>, testKey: string): () => {
2
+ t: (key: string, options?: Record<string, unknown>) => string;
3
+ } | {
4
+ t: import("i18next").TFunction<string, undefined>;
5
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Safe translation hook with fallback to default strings.
3
+ *
4
+ * When no I18nProvider is available (e.g., in tests or standalone usage),
5
+ * this hook falls back to the provided default translations instead of
6
+ * returning raw i18n keys.
7
+ *
8
+ * @param defaults - Fallback English translations keyed by i18n key
9
+ * @param testKey - A key to test if i18n is properly configured (must be in defaults)
10
+ */
11
+ import { useObjectTranslation } from './provider';
12
+ export function createSafeTranslation(defaults, testKey) {
13
+ return function useSafeTranslation() {
14
+ try {
15
+ const result = useObjectTranslation();
16
+ const testValue = result.t(testKey);
17
+ if (testValue === testKey) {
18
+ return {
19
+ t: (key, options) => {
20
+ let value = defaults[key] || key;
21
+ if (options) {
22
+ for (const [k, v] of Object.entries(options)) {
23
+ value = value.replace(`{{${k}}}`, String(v));
24
+ }
25
+ }
26
+ return value;
27
+ },
28
+ };
29
+ }
30
+ return { t: result.t };
31
+ }
32
+ catch {
33
+ return {
34
+ t: (key, options) => {
35
+ let value = defaults[key] || key;
36
+ if (options) {
37
+ for (const [k, v] of Object.entries(options)) {
38
+ value = value.replace(`{{${k}}}`, String(v));
39
+ }
40
+ }
41
+ return value;
42
+ },
43
+ };
44
+ }
45
+ };
46
+ }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@object-ui/i18n",
3
- "version": "3.1.5",
3
+ "version": "3.3.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
7
7
  "description": "Internationalization (i18n) support for Object UI with 10+ language packs, RTL layout, and date/currency formatting.",
8
- "homepage": "https://www.objectui.org",
8
+ "homepage": "https://www.objectui.org/docs/packages/i18n",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/objectstack-ai/objectui.git",
11
+ "url": "git+https://github.com/objectstack-ai/objectui.git",
12
12
  "directory": "packages/i18n"
13
13
  },
14
14
  "bugs": {
@@ -30,18 +30,41 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "i18next": "^25.10.4",
34
- "react-i18next": "^16.6.2"
33
+ "i18next": "^26.0.8",
34
+ "react-i18next": "^17.0.6"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18.0.0 || ^19.0.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/react": "19.2.14",
41
- "react": "19.2.4",
42
- "typescript": "^5.9.3",
43
- "vitest": "^4.1.0"
41
+ "react": "19.2.5",
42
+ "typescript": "^6.0.3",
43
+ "vitest": "^4.1.5"
44
44
  },
45
+ "keywords": [
46
+ "objectui",
47
+ "sdui",
48
+ "schema-driven-ui",
49
+ "react",
50
+ "tailwind",
51
+ "shadcn",
52
+ "objectstack",
53
+ "i18n",
54
+ "internationalization",
55
+ "localization",
56
+ "rtl"
57
+ ],
58
+ "author": "ObjectStack Team <team@objectstack.ai>",
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "files": [
63
+ "dist",
64
+ "README.md",
65
+ "CHANGELOG.md",
66
+ "LICENSE"
67
+ ],
45
68
  "scripts": {
46
69
  "build": "tsc",
47
70
  "test": "vitest run",
@@ -1,4 +0,0 @@
1
-
2
- > @object-ui/i18n@3.1.5 build /home/runner/work/objectui/objectui/packages/i18n
3
- > tsc
4
-
@@ -1,228 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import {
3
- createI18n,
4
- getDirection,
5
- getAvailableLanguages,
6
- builtInLocales,
7
- isRTL,
8
- RTL_LANGUAGES,
9
- formatDate,
10
- formatDateTime,
11
- formatCurrency,
12
- formatNumber,
13
- } from '../index';
14
-
15
- describe('@object-ui/i18n', () => {
16
- describe('createI18n', () => {
17
- it('creates an i18next instance with default config', () => {
18
- const i18n = createI18n({ detectBrowserLanguage: false });
19
- expect(i18n).toBeDefined();
20
- expect(i18n.language).toBe('en');
21
- });
22
-
23
- it('creates an instance with specified default language', () => {
24
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
25
- expect(i18n.language).toBe('zh');
26
- });
27
-
28
- it('loads all built-in locales', () => {
29
- const i18n = createI18n({ detectBrowserLanguage: false });
30
- const langs = getAvailableLanguages(i18n);
31
- expect(langs).toContain('en');
32
- expect(langs).toContain('zh');
33
- expect(langs).toContain('ja');
34
- expect(langs).toContain('ko');
35
- expect(langs).toContain('de');
36
- expect(langs).toContain('fr');
37
- expect(langs).toContain('es');
38
- expect(langs).toContain('pt');
39
- expect(langs).toContain('ru');
40
- expect(langs).toContain('ar');
41
- expect(langs.length).toBeGreaterThanOrEqual(10);
42
- });
43
-
44
- it('translates common keys in English', () => {
45
- const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
46
- expect(i18n.t('common.save')).toBe('Save');
47
- expect(i18n.t('common.cancel')).toBe('Cancel');
48
- expect(i18n.t('common.delete')).toBe('Delete');
49
- expect(i18n.t('common.loading')).toBe('Loading...');
50
- });
51
-
52
- it('translates common keys in Chinese', () => {
53
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
54
- expect(i18n.t('common.save')).toBe('保存');
55
- expect(i18n.t('common.cancel')).toBe('取消');
56
- expect(i18n.t('common.delete')).toBe('删除');
57
- expect(i18n.t('common.loading')).toBe('加载中...');
58
- });
59
-
60
- it('translates common keys in Japanese', () => {
61
- const i18n = createI18n({ defaultLanguage: 'ja', detectBrowserLanguage: false });
62
- expect(i18n.t('common.save')).toBe('保存');
63
- expect(i18n.t('common.cancel')).toBe('キャンセル');
64
- });
65
-
66
- it('translates common keys in Korean', () => {
67
- const i18n = createI18n({ defaultLanguage: 'ko', detectBrowserLanguage: false });
68
- expect(i18n.t('common.save')).toBe('저장');
69
- expect(i18n.t('common.cancel')).toBe('취소');
70
- });
71
-
72
- it('supports interpolation in validation messages', () => {
73
- const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
74
- expect(i18n.t('validation.required', { field: 'Name' })).toBe('Name is required');
75
- expect(i18n.t('validation.minLength', { field: 'Password', min: 8 })).toBe(
76
- 'Password must be at least 8 characters',
77
- );
78
- });
79
-
80
- it('supports interpolation in Chinese', () => {
81
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
82
- expect(i18n.t('validation.required', { field: '姓名' })).toBe('姓名不能为空');
83
- });
84
-
85
- it('falls back to English for unknown language', () => {
86
- const i18n = createI18n({ defaultLanguage: 'xx', fallbackLanguage: 'en', detectBrowserLanguage: false });
87
- expect(i18n.t('common.save')).toBe('Save');
88
- });
89
-
90
- it('merges custom resources with built-in locales', () => {
91
- const i18n = createI18n({
92
- defaultLanguage: 'en',
93
- detectBrowserLanguage: false,
94
- resources: {
95
- en: { custom: { greeting: 'Hello!' } },
96
- },
97
- });
98
- expect(i18n.t('custom.greeting')).toBe('Hello!');
99
- // Built-in translations still work
100
- expect(i18n.t('common.save')).toBe('Save');
101
- });
102
-
103
- it('changes language dynamically', async () => {
104
- const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
105
- expect(i18n.t('common.save')).toBe('Save');
106
-
107
- await i18n.changeLanguage('zh');
108
- expect(i18n.language).toBe('zh');
109
- expect(i18n.t('common.save')).toBe('保存');
110
-
111
- await i18n.changeLanguage('ja');
112
- expect(i18n.language).toBe('ja');
113
- expect(i18n.t('common.save')).toBe('保存');
114
- });
115
- });
116
-
117
- describe('getDirection', () => {
118
- it('returns ltr for English', () => {
119
- expect(getDirection('en')).toBe('ltr');
120
- });
121
-
122
- it('returns ltr for Chinese', () => {
123
- expect(getDirection('zh')).toBe('ltr');
124
- });
125
-
126
- it('returns rtl for Arabic', () => {
127
- expect(getDirection('ar')).toBe('rtl');
128
- });
129
-
130
- it('returns ltr for unknown languages', () => {
131
- expect(getDirection('xx')).toBe('ltr');
132
- });
133
- });
134
-
135
- describe('isRTL', () => {
136
- it('returns true for Arabic', () => {
137
- expect(isRTL('ar')).toBe(true);
138
- });
139
-
140
- it('returns false for English', () => {
141
- expect(isRTL('en')).toBe(false);
142
- });
143
-
144
- it('returns false for Chinese', () => {
145
- expect(isRTL('zh')).toBe(false);
146
- });
147
- });
148
-
149
- describe('RTL_LANGUAGES', () => {
150
- it('includes Arabic', () => {
151
- expect(RTL_LANGUAGES).toContain('ar');
152
- });
153
-
154
- it('includes Hebrew', () => {
155
- expect(RTL_LANGUAGES).toContain('he');
156
- });
157
- });
158
-
159
- describe('builtInLocales', () => {
160
- it('has 10 built-in language packs', () => {
161
- expect(Object.keys(builtInLocales).length).toBe(10);
162
- });
163
-
164
- it('all locales have the same top-level keys', () => {
165
- const enKeys = Object.keys(builtInLocales.en).sort();
166
- for (const [lang, locale] of Object.entries(builtInLocales)) {
167
- const keys = Object.keys(locale).sort();
168
- expect(keys).toEqual(enKeys);
169
- }
170
- });
171
-
172
- it('all locales have common section keys matching English', () => {
173
- const enCommonKeys = Object.keys(builtInLocales.en.common).sort();
174
- for (const [lang, locale] of Object.entries(builtInLocales)) {
175
- const keys = Object.keys(locale.common).sort();
176
- expect(keys).toEqual(enCommonKeys);
177
- }
178
- });
179
- });
180
-
181
- describe('formatDate', () => {
182
- it('formats a date with default options', () => {
183
- const result = formatDate(new Date(2026, 0, 15), { locale: 'en' });
184
- expect(result).toContain('Jan');
185
- expect(result).toContain('15');
186
- expect(result).toContain('2026');
187
- });
188
-
189
- it('formats a date with short style', () => {
190
- const result = formatDate(new Date(2026, 0, 15), { locale: 'en', style: 'short' });
191
- expect(result).toContain('15');
192
- });
193
-
194
- it('returns string for invalid dates', () => {
195
- const result = formatDate('invalid-date');
196
- expect(result).toBe('invalid-date');
197
- });
198
- });
199
-
200
- describe('formatCurrency', () => {
201
- it('formats USD by default', () => {
202
- const result = formatCurrency(1234.56, { locale: 'en' });
203
- expect(result).toContain('1,234.56');
204
- });
205
-
206
- it('formats EUR', () => {
207
- const result = formatCurrency(1234.56, { locale: 'de', currency: 'EUR' });
208
- expect(result).toContain('1.234,56');
209
- });
210
-
211
- it('formats CNY', () => {
212
- const result = formatCurrency(1234.56, { locale: 'zh', currency: 'CNY' });
213
- expect(result).toContain('1,234.56');
214
- });
215
- });
216
-
217
- describe('formatNumber', () => {
218
- it('formats a number with default locale', () => {
219
- const result = formatNumber(1234567.89, { locale: 'en' });
220
- expect(result).toContain('1,234,567.89');
221
- });
222
-
223
- it('formats with compact notation', () => {
224
- const result = formatNumber(1234567, { locale: 'en', notation: 'compact' });
225
- expect(result).toContain('M'); // e.g. 1.2M
226
- });
227
- });
228
- });
@@ -1,117 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { renderHook, waitFor, act } from '@testing-library/react';
3
- import React from 'react';
4
- import { I18nProvider, useObjectTranslation, useI18nContext } from '../provider';
5
- import { createI18n } from '../i18n';
6
-
7
- describe('I18nProvider', () => {
8
- it('creates i18n instance from config', () => {
9
- const wrapper = ({ children }: { children: React.ReactNode }) =>
10
- React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children);
11
-
12
- const { result } = renderHook(() => useObjectTranslation(), { wrapper });
13
-
14
- expect(result.current.i18n).toBeDefined();
15
- expect(result.current.language).toBe('en');
16
- });
17
-
18
- it('accepts pre-created instance', () => {
19
- const instance = createI18n({ defaultLanguage: 'fr', detectBrowserLanguage: false });
20
-
21
- const wrapper = ({ children }: { children: React.ReactNode }) =>
22
- React.createElement(I18nProvider, { instance }, children);
23
-
24
- const { result } = renderHook(() => useObjectTranslation(), { wrapper });
25
-
26
- expect(result.current.i18n).toBeDefined();
27
- expect(result.current.language).toBe('fr');
28
- });
29
- });
30
-
31
- describe('useObjectTranslation', () => {
32
- const wrapper = ({ children }: { children: React.ReactNode }) =>
33
- React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children);
34
-
35
- it('returns t, language, changeLanguage, direction, i18n', () => {
36
- const { result } = renderHook(() => useObjectTranslation(), { wrapper });
37
-
38
- expect(result.current.t).toBeTypeOf('function');
39
- expect(result.current.language).toBeTypeOf('string');
40
- expect(result.current.changeLanguage).toBeTypeOf('function');
41
- expect(result.current.direction).toBeTypeOf('string');
42
- expect(result.current.i18n).toBeDefined();
43
- });
44
-
45
- it('translates keys correctly', () => {
46
- const { result } = renderHook(() => useObjectTranslation(), { wrapper });
47
-
48
- expect(result.current.t('common.save')).toBe('Save');
49
- expect(result.current.t('common.cancel')).toBe('Cancel');
50
- expect(result.current.t('common.delete')).toBe('Delete');
51
- });
52
-
53
- it('returns en as default language', () => {
54
- const { result } = renderHook(() => useObjectTranslation(), { wrapper });
55
-
56
- expect(result.current.language).toBe('en');
57
- });
58
-
59
- it('works with Chinese language', () => {
60
- const zhWrapper = ({ children }: { children: React.ReactNode }) =>
61
- React.createElement(I18nProvider, { config: { defaultLanguage: 'zh', detectBrowserLanguage: false } }, children);
62
-
63
- const { result } = renderHook(() => useObjectTranslation(), { wrapper: zhWrapper });
64
-
65
- expect(result.current.language).toBe('zh');
66
- expect(result.current.t('common.save')).toBe('保存');
67
- expect(result.current.t('common.cancel')).toBe('取消');
68
- });
69
-
70
- it('changeLanguage updates language', async () => {
71
- const { result } = renderHook(() => useObjectTranslation(), { wrapper });
72
-
73
- expect(result.current.language).toBe('en');
74
-
75
- await act(async () => {
76
- await result.current.changeLanguage('zh');
77
- });
78
-
79
- await waitFor(() => {
80
- expect(result.current.language).toBe('zh');
81
- });
82
- });
83
-
84
- it('returns RTL direction for Arabic', () => {
85
- const arWrapper = ({ children }: { children: React.ReactNode }) =>
86
- React.createElement(I18nProvider, { config: { defaultLanguage: 'ar', detectBrowserLanguage: false } }, children);
87
-
88
- const { result } = renderHook(() => useObjectTranslation(), { wrapper: arWrapper });
89
-
90
- expect(result.current.direction).toBe('rtl');
91
- });
92
- });
93
-
94
- describe('useI18nContext', () => {
95
- it('throws when used outside provider', () => {
96
- // Suppress console.error from React for the expected error
97
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
98
-
99
- expect(() => {
100
- renderHook(() => useI18nContext());
101
- }).toThrow('useI18nContext must be used within an I18nProvider');
102
-
103
- spy.mockRestore();
104
- });
105
-
106
- it('returns context inside provider', () => {
107
- const wrapper = ({ children }: { children: React.ReactNode }) =>
108
- React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children);
109
-
110
- const { result } = renderHook(() => useI18nContext(), { wrapper });
111
-
112
- expect(result.current.language).toBe('en');
113
- expect(result.current.changeLanguage).toBeTypeOf('function');
114
- expect(result.current.direction).toBe('ltr');
115
- expect(result.current.i18n).toBeDefined();
116
- });
117
- });