@object-ui/i18n 3.3.0 → 3.3.2

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 (53) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +21 -1
  3. package/dist/i18n.js +10 -1
  4. package/dist/locales/ar.d.ts +58 -0
  5. package/dist/locales/ar.js +58 -0
  6. package/dist/locales/de.d.ts +58 -0
  7. package/dist/locales/de.js +58 -0
  8. package/dist/locales/en.d.ts +78 -0
  9. package/dist/locales/en.js +78 -0
  10. package/dist/locales/es.d.ts +58 -0
  11. package/dist/locales/es.js +58 -0
  12. package/dist/locales/fr.d.ts +58 -0
  13. package/dist/locales/fr.js +58 -0
  14. package/dist/locales/index.d.ts +620 -0
  15. package/dist/locales/ja.d.ts +58 -0
  16. package/dist/locales/ja.js +58 -0
  17. package/dist/locales/ko.d.ts +58 -0
  18. package/dist/locales/ko.js +58 -0
  19. package/dist/locales/pt.d.ts +58 -0
  20. package/dist/locales/pt.js +58 -0
  21. package/dist/locales/ru.d.ts +58 -0
  22. package/dist/locales/ru.js +58 -0
  23. package/dist/locales/zh.d.ts +78 -0
  24. package/dist/locales/zh.js +78 -0
  25. package/dist/useObjectLabel.d.ts +46 -0
  26. package/dist/useObjectLabel.js +79 -12
  27. package/package.json +30 -7
  28. package/.turbo/turbo-build.log +0 -4
  29. package/src/__tests__/i18n.test.ts +0 -255
  30. package/src/__tests__/provider.test.tsx +0 -117
  31. package/src/__tests__/spec-formatters.test.ts +0 -208
  32. package/src/__tests__/useObjectLabel.test.tsx +0 -131
  33. package/src/i18n.ts +0 -98
  34. package/src/index.ts +0 -81
  35. package/src/locales/ar.ts +0 -742
  36. package/src/locales/de.ts +0 -755
  37. package/src/locales/en.ts +0 -836
  38. package/src/locales/es.ts +0 -741
  39. package/src/locales/fr.ts +0 -755
  40. package/src/locales/index.ts +0 -42
  41. package/src/locales/ja.ts +0 -752
  42. package/src/locales/ko.ts +0 -741
  43. package/src/locales/pt.ts +0 -741
  44. package/src/locales/ru.ts +0 -761
  45. package/src/locales/zh.ts +0 -835
  46. package/src/provider.tsx +0 -177
  47. package/src/useObjectLabel.ts +0 -122
  48. package/src/useSafeTranslation.ts +0 -49
  49. package/src/utils/formatting.ts +0 -136
  50. package/src/utils/index.ts +0 -21
  51. package/src/utils/spec-formatters.ts +0 -220
  52. package/tsconfig.json +0 -12
  53. package/tsconfig.tsbuildinfo +0 -1
@@ -559,6 +559,17 @@ const zh = {
559
559
  ungrouped: '通用',
560
560
  deleteConfirmTitle: '删除字段?',
561
561
  deleteConfirmMessage: '这将永久删除该字段。此字段中的现有数据将丢失。',
562
+ basicSection: '基础',
563
+ typeSpecificSection: '类型设置',
564
+ advancedSection: '高级',
565
+ typeCategory: {
566
+ text: '文本',
567
+ number: '数字',
568
+ date: '日期时间',
569
+ choice: '选择',
570
+ relation: '关联',
571
+ advanced: '高级',
572
+ },
562
573
  },
563
574
  },
564
575
  console: {
@@ -575,6 +586,16 @@ const zh = {
575
586
  loadingConfig: '正在加载配置',
576
587
  preparingWorkspace: '正在准备工作区',
577
588
  },
589
+ error: {
590
+ connectionFailed: '无法连接到服务器',
591
+ serverUnreachable: '无法访问服务器 {{url}}。',
592
+ checkServer: '请检查网络连接,或确认后端服务正在运行。',
593
+ timeout: '连接超时(10 秒)。',
594
+ },
595
+ actions: {
596
+ retry: '重试',
597
+ retrying: '正在重试…',
598
+ },
578
599
  shortcuts: {
579
600
  title: '键盘快捷键',
580
601
  description: '所有可用键盘快捷键的快速参考。',
@@ -830,5 +851,62 @@ const zh = {
830
851
  timeout: '请求超时,请重试。',
831
852
  unknown: '发生未知错误。',
832
853
  },
854
+ workspace: {
855
+ label: '工作区',
856
+ default: '我的工作区',
857
+ switch: '切换工作区',
858
+ create: '创建工作区',
859
+ createTitle: '创建工作区',
860
+ createDescription: '工作区是团队协作的共享空间。',
861
+ createButton: '创建工作区',
862
+ nameLabel: '工作区名称',
863
+ namePlaceholder: '例如:极客科技',
864
+ slugLabel: 'URL 标识',
865
+ slugHint: '用于 URL 中,仅支持小写字母、数字和连字符。',
866
+ invite: '邀请成员',
867
+ members: '成员',
868
+ settings: '工作区设置',
869
+ },
870
+ sidebar: {
871
+ settings: '设置',
872
+ help: '帮助',
873
+ helpTooltip: '帮助与文档',
874
+ activityFeed: '活动动态',
875
+ },
876
+ home: {
877
+ title: '首页',
878
+ subtitle: '您的工作区仪表盘',
879
+ nav: '首页',
880
+ allApps: '全部应用',
881
+ loading: '正在加载工作区...',
882
+ recent: '最近使用',
883
+ starred: '收藏',
884
+ welcome: '欢迎使用 ObjectUI',
885
+ welcomeDescription: '从创建您的第一个应用开始,或者先完成系统设置。',
886
+ createFirstApp: '创建您的第一个应用',
887
+ systemSettings: '系统设置',
888
+ quickActions: {
889
+ title: '快捷操作',
890
+ createApp: '创建应用',
891
+ createAppDesc: '从新应用开始',
892
+ manageObjects: '管理对象',
893
+ manageObjectsDesc: '配置数据模型',
894
+ systemSettings: '系统设置',
895
+ systemSettingsDesc: '配置您的工作区',
896
+ },
897
+ recentApps: {
898
+ title: '最近访问',
899
+ itemType: {
900
+ object: '对象',
901
+ dashboard: '仪表盘',
902
+ page: '页面',
903
+ record: '记录',
904
+ },
905
+ },
906
+ appCard: {
907
+ noDescription: '暂无描述',
908
+ default: '默认',
909
+ },
910
+ },
833
911
  };
834
912
  export default zh;
@@ -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
  }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@object-ui/i18n",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
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,8 +30,8 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "i18next": "^26.0.4",
34
- "react-i18next": "^17.0.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"
@@ -39,9 +39,32 @@
39
39
  "devDependencies": {
40
40
  "@types/react": "19.2.14",
41
41
  "react": "19.2.5",
42
- "typescript": "^6.0.2",
43
- "vitest": "^4.1.4"
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.3.0 build /home/runner/work/objectui/objectui/packages/i18n
3
- > tsc
4
-
@@ -1,255 +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
- expect(i18n.t('common.selectOption')).toBe('Select an option');
51
- expect(i18n.t('common.select')).toBe('Select...');
52
- });
53
-
54
- it('translates new form keys in English', () => {
55
- const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
56
- expect(i18n.t('form.createTitle', { object: 'Contact' })).toBe('Create Contact');
57
- expect(i18n.t('form.editTitle', { object: 'Contact' })).toBe('Edit Contact');
58
- expect(i18n.t('form.createDescription', { object: 'Contact' })).toBe('Add a new Contact to your database.');
59
- expect(i18n.t('form.editDescription', { object: 'Contact' })).toBe('Update details for Contact');
60
- expect(i18n.t('form.saveRecord')).toBe('Save Record');
61
- });
62
-
63
- it('translates common keys in Chinese', () => {
64
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
65
- expect(i18n.t('common.save')).toBe('保存');
66
- expect(i18n.t('common.cancel')).toBe('取消');
67
- expect(i18n.t('common.delete')).toBe('删除');
68
- expect(i18n.t('common.loading')).toBe('加载中...');
69
- expect(i18n.t('common.selectOption')).toBe('请选择');
70
- expect(i18n.t('common.select')).toBe('选择...');
71
- });
72
-
73
- it('translates new form keys in Chinese', () => {
74
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
75
- expect(i18n.t('form.createTitle', { object: '联系人' })).toBe('新建联系人');
76
- expect(i18n.t('form.editTitle', { object: '联系人' })).toBe('编辑联系人');
77
- expect(i18n.t('form.createDescription', { object: '联系人' })).toBe('向数据库添加新的联系人。');
78
- expect(i18n.t('form.editDescription', { object: '联系人' })).toBe('更新联系人的详情');
79
- expect(i18n.t('form.saveRecord')).toBe('保存记录');
80
- });
81
-
82
- it('translates toolbarEnabledCount in Chinese (not English fallback)', () => {
83
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
84
- expect(i18n.t('console.objectView.toolbarEnabledCount', { count: 3, total: 5 })).toBe('已启用 3/5 项');
85
- });
86
-
87
- it('translates common keys in Japanese', () => {
88
- const i18n = createI18n({ defaultLanguage: 'ja', detectBrowserLanguage: false });
89
- expect(i18n.t('common.save')).toBe('保存');
90
- expect(i18n.t('common.cancel')).toBe('キャンセル');
91
- });
92
-
93
- it('translates common keys in Korean', () => {
94
- const i18n = createI18n({ defaultLanguage: 'ko', detectBrowserLanguage: false });
95
- expect(i18n.t('common.save')).toBe('저장');
96
- expect(i18n.t('common.cancel')).toBe('취소');
97
- });
98
-
99
- it('supports interpolation in validation messages', () => {
100
- const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
101
- expect(i18n.t('validation.required', { field: 'Name' })).toBe('Name is required');
102
- expect(i18n.t('validation.minLength', { field: 'Password', min: 8 })).toBe(
103
- 'Password must be at least 8 characters',
104
- );
105
- });
106
-
107
- it('supports interpolation in Chinese', () => {
108
- const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
109
- expect(i18n.t('validation.required', { field: '姓名' })).toBe('姓名不能为空');
110
- });
111
-
112
- it('falls back to English for unknown language', () => {
113
- const i18n = createI18n({ defaultLanguage: 'xx', fallbackLanguage: 'en', detectBrowserLanguage: false });
114
- expect(i18n.t('common.save')).toBe('Save');
115
- });
116
-
117
- it('merges custom resources with built-in locales', () => {
118
- const i18n = createI18n({
119
- defaultLanguage: 'en',
120
- detectBrowserLanguage: false,
121
- resources: {
122
- en: { custom: { greeting: 'Hello!' } },
123
- },
124
- });
125
- expect(i18n.t('custom.greeting')).toBe('Hello!');
126
- // Built-in translations still work
127
- expect(i18n.t('common.save')).toBe('Save');
128
- });
129
-
130
- it('changes language dynamically', async () => {
131
- const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
132
- expect(i18n.t('common.save')).toBe('Save');
133
-
134
- await i18n.changeLanguage('zh');
135
- expect(i18n.language).toBe('zh');
136
- expect(i18n.t('common.save')).toBe('保存');
137
-
138
- await i18n.changeLanguage('ja');
139
- expect(i18n.language).toBe('ja');
140
- expect(i18n.t('common.save')).toBe('保存');
141
- });
142
- });
143
-
144
- describe('getDirection', () => {
145
- it('returns ltr for English', () => {
146
- expect(getDirection('en')).toBe('ltr');
147
- });
148
-
149
- it('returns ltr for Chinese', () => {
150
- expect(getDirection('zh')).toBe('ltr');
151
- });
152
-
153
- it('returns rtl for Arabic', () => {
154
- expect(getDirection('ar')).toBe('rtl');
155
- });
156
-
157
- it('returns ltr for unknown languages', () => {
158
- expect(getDirection('xx')).toBe('ltr');
159
- });
160
- });
161
-
162
- describe('isRTL', () => {
163
- it('returns true for Arabic', () => {
164
- expect(isRTL('ar')).toBe(true);
165
- });
166
-
167
- it('returns false for English', () => {
168
- expect(isRTL('en')).toBe(false);
169
- });
170
-
171
- it('returns false for Chinese', () => {
172
- expect(isRTL('zh')).toBe(false);
173
- });
174
- });
175
-
176
- describe('RTL_LANGUAGES', () => {
177
- it('includes Arabic', () => {
178
- expect(RTL_LANGUAGES).toContain('ar');
179
- });
180
-
181
- it('includes Hebrew', () => {
182
- expect(RTL_LANGUAGES).toContain('he');
183
- });
184
- });
185
-
186
- describe('builtInLocales', () => {
187
- it('has 10 built-in language packs', () => {
188
- expect(Object.keys(builtInLocales).length).toBe(10);
189
- });
190
-
191
- it('all locales have the same top-level keys', () => {
192
- const enKeys = Object.keys(builtInLocales.en).sort();
193
- for (const [lang, locale] of Object.entries(builtInLocales)) {
194
- const keys = Object.keys(locale).sort();
195
- expect(keys).toEqual(enKeys);
196
- }
197
- });
198
-
199
- it('all locales have common section keys matching English', () => {
200
- const enCommonKeys = Object.keys(builtInLocales.en.common).sort();
201
- for (const [lang, locale] of Object.entries(builtInLocales)) {
202
- const keys = Object.keys(locale.common).sort();
203
- expect(keys).toEqual(enCommonKeys);
204
- }
205
- });
206
- });
207
-
208
- describe('formatDate', () => {
209
- it('formats a date with default options', () => {
210
- const result = formatDate(new Date(2026, 0, 15), { locale: 'en' });
211
- expect(result).toContain('Jan');
212
- expect(result).toContain('15');
213
- expect(result).toContain('2026');
214
- });
215
-
216
- it('formats a date with short style', () => {
217
- const result = formatDate(new Date(2026, 0, 15), { locale: 'en', style: 'short' });
218
- expect(result).toContain('15');
219
- });
220
-
221
- it('returns string for invalid dates', () => {
222
- const result = formatDate('invalid-date');
223
- expect(result).toBe('invalid-date');
224
- });
225
- });
226
-
227
- describe('formatCurrency', () => {
228
- it('formats USD by default', () => {
229
- const result = formatCurrency(1234.56, { locale: 'en' });
230
- expect(result).toContain('1,234.56');
231
- });
232
-
233
- it('formats EUR', () => {
234
- const result = formatCurrency(1234.56, { locale: 'de', currency: 'EUR' });
235
- expect(result).toContain('1.234,56');
236
- });
237
-
238
- it('formats CNY', () => {
239
- const result = formatCurrency(1234.56, { locale: 'zh', currency: 'CNY' });
240
- expect(result).toContain('1,234.56');
241
- });
242
- });
243
-
244
- describe('formatNumber', () => {
245
- it('formats a number with default locale', () => {
246
- const result = formatNumber(1234567.89, { locale: 'en' });
247
- expect(result).toContain('1,234,567.89');
248
- });
249
-
250
- it('formats with compact notation', () => {
251
- const result = formatNumber(1234567, { locale: 'en', notation: 'compact' });
252
- expect(result).toContain('M'); // e.g. 1.2M
253
- });
254
- });
255
- });