@object-ui/i18n 17.2.0 → 17.3.0

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.
@@ -81,6 +81,10 @@ declare const zh: {
81
81
  readonly select: "选择...";
82
82
  readonly openChat: "打开聊天";
83
83
  readonly closeChat: "关闭聊天";
84
+ readonly closePanel: "关闭面板";
85
+ readonly resizeDrawer: "调整面板宽度";
86
+ readonly itemCount: "{{count}} 项";
87
+ readonly itemCountOne: "{{count}} 项";
84
88
  readonly toggleSidebar: "切换侧边栏";
85
89
  readonly package: "软件包";
86
90
  };
@@ -105,6 +109,7 @@ declare const zh: {
105
109
  readonly url: "请输入有效的URL";
106
110
  readonly pattern: "{{field}}格式不正确";
107
111
  readonly formInvalid: "请检查表单中标记的字段:{{fields}}";
112
+ readonly formInvalidJoiner: "、";
108
113
  readonly unique: "{{field}}必须唯一";
109
114
  readonly type: "{{field}}必须是有效的{{type}}";
110
115
  };
@@ -129,12 +134,21 @@ declare const zh: {
129
134
  readonly stepOf: "第{{current}}步,共{{total}}步";
130
135
  readonly createTitle: "新建{{object}}";
131
136
  readonly editTitle: "编辑{{object}}";
137
+ readonly viewTitle: "查看{{object}}";
138
+ readonly createTargetOrg: "创建到{{org}}";
132
139
  readonly saveRecord: "保存";
133
140
  readonly create: "创建";
134
141
  readonly update: "更新";
135
142
  readonly createSuccess: "{{object}}创建成功";
136
143
  readonly updateSuccess: "{{object}}更新成功";
137
144
  readonly deleteSuccess: "{{object}}删除成功";
145
+ readonly fullscreen: {
146
+ readonly title: "编辑文本";
147
+ readonly description: "编辑完整的文本内容,然后保存或取消更改。";
148
+ readonly done: "完成";
149
+ readonly toggle: "全屏编辑{{label}}";
150
+ readonly textFallback: "文本";
151
+ };
138
152
  };
139
153
  readonly fields: {
140
154
  readonly relativeDate: {
@@ -174,6 +188,10 @@ declare const zh: {
174
188
  readonly basicEditorHint: "富文本编辑器(基础)";
175
189
  readonly placeholder: "请输入文字...";
176
190
  };
191
+ readonly options: {
192
+ readonly empty: "暂无可选项";
193
+ readonly selectFirst: "请先选择{{fields}}";
194
+ };
177
195
  readonly objectRef: {
178
196
  readonly loading: "正在加载对象…";
179
197
  readonly placeholder: "请选择对象";
@@ -201,6 +219,13 @@ declare const zh: {
201
219
  readonly editAsJson: "以 JSON 编辑";
202
220
  readonly useVisualBuilder: "使用可视化构建器";
203
221
  };
222
+ readonly tags: {
223
+ readonly placeholder: "输入后回车添加…";
224
+ };
225
+ readonly textarea: {
226
+ readonly characterCount: "已输入 {{count}} 个字符,最多 {{max}} 个";
227
+ readonly charactersRemaining: "还可输入 {{count}} 个字符";
228
+ };
204
229
  };
205
230
  readonly table: {
206
231
  readonly rowsPerPage: "每页行数";
@@ -666,6 +691,12 @@ declare const zh: {
666
691
  readonly over: "超载";
667
692
  readonly empty: "没有可分配的任务。";
668
693
  };
694
+ readonly quickFilter: {
695
+ readonly all: "全部";
696
+ readonly clear: "清除筛选";
697
+ readonly empty: "无可选项";
698
+ readonly resultSummary: "显示 {shown} / {total} 项任务";
699
+ };
669
700
  readonly readOnly: "只读";
670
701
  readonly readOnlyHint: "此视图已禁用编辑。";
671
702
  readonly lockedHint: "无编辑权限";
@@ -716,14 +747,15 @@ declare const zh: {
716
747
  readonly editInlineHint: "双击编辑";
717
748
  readonly lockedByApproval: "审批中已锁定";
718
749
  readonly lockedTooltip: "该记录有待审批的请求,编辑已被锁定";
719
- readonly writeStrippedTitle: "部分字段未保存";
720
- readonly writeStrippedReadonly: "以下字段为只读,未保存:{{fields}}";
721
- readonly writeStrippedByState: "以下字段在记录当前状态下不可编辑,未保存:{{fields}}";
750
+ readonly writeStrippedTitle: "已保存,但部分字段未生效";
751
+ readonly writeStrippedReadonly: "以下字段为只读,未生效:{{fields}}";
752
+ readonly writeStrippedByState: "以下字段在记录当前状态下不可编辑,未生效:{{fields}}";
722
753
  readonly approvalPendingEditable: "审批中 · 可编辑";
723
754
  readonly approvalPendingTooltip: "该记录有待审批的请求,但当前审批节点仍允许编辑";
724
755
  readonly approvalProgress: "审批 — 已通过 {{got}} / 共需 {{need}}";
725
756
  readonly approvalProgressGroups: "会签 — 已完成 {{got}} / 共 {{need}} 个组";
726
757
  readonly approvalProgressLabel: "审批进度";
758
+ readonly approvalsPanelTitle: "审批";
727
759
  readonly cancelApproval: "撤回审批";
728
760
  readonly cancelApprovalInFlight: "撤回中…";
729
761
  readonly cancelApprovalTooltip: "撤回当前的待审批请求以解除记录锁定";
@@ -751,6 +783,15 @@ declare const zh: {
751
783
  readonly viewHistory: "查看历史";
752
784
  readonly delete: "删除";
753
785
  readonly moreActions: "更多操作";
786
+ readonly addReaction: "添加表情回应";
787
+ readonly pageHeaderActions: "页面标题栏操作";
788
+ readonly emojiPicker: "表情选择器";
789
+ readonly reactionCount: "{{emoji}} {{count}} 个回应";
790
+ readonly reactionCountOne: "{{emoji}} {{count}} 个回应";
791
+ readonly recordDetail: "记录详情";
792
+ readonly recordDetailWithLabel: "{{label}}详情";
793
+ readonly openAsFullPage: "以完整页面打开";
794
+ readonly recordDetailOverlay: "{{title}} 的记录详情浮层。";
754
795
  readonly addToFavorites: "添加到收藏";
755
796
  readonly removeFromFavorites: "从收藏中移除";
756
797
  readonly previousRecord: "上一条记录";
@@ -1378,6 +1419,13 @@ declare const zh: {
1378
1419
  readonly retry: "重试";
1379
1420
  readonly retrying: "正在重试…";
1380
1421
  };
1422
+ readonly serverAction: {
1423
+ readonly openingTitle: "正在打开…";
1424
+ readonly openingBody: "正在打开…可能需要一点时间。";
1425
+ readonly popupBlockedTitle: "浏览器拦截了弹窗";
1426
+ readonly popupBlockedDescription: "浏览器阻止了新标签页的打开。";
1427
+ readonly popupBlockedAction: "在新标签页打开";
1428
+ };
1381
1429
  readonly shortcuts: {
1382
1430
  readonly title: "键盘快捷键";
1383
1431
  readonly description: "所有可用键盘快捷键的快速参考。";
@@ -1615,6 +1663,8 @@ declare const zh: {
1615
1663
  readonly objectNotFound: "未找到对象";
1616
1664
  readonly objectNotFoundDescription: "对象\"{{objectName}}\"在当前配置中不存在。";
1617
1665
  readonly objectNotFoundHint: "请检查您的应用导航设置或从侧边栏选择其他对象。";
1666
+ readonly filterOrNotSavable: "该筛选条件之间使用「或」,保存到视图暂不支持这种结构。它仍会作用于当前列表 —— 取消「或」分组后才能保存到视图。";
1667
+ readonly filterNestedNotSavable: "该筛选使用了嵌套条件分组,保存到视图不支持这种结构。请改为单层条件列表后再保存到视图。";
1618
1668
  readonly allRecords: "所有记录";
1619
1669
  readonly exitDesignMode: "退出设计模式";
1620
1670
  readonly enterDesignMode: "进入设计模式";
@@ -3007,5 +3057,39 @@ declare const zh: {
3007
3057
  readonly addSort: "添加排序";
3008
3058
  readonly removeSort: "删除排序";
3009
3059
  };
3060
+ readonly collaboration: {
3061
+ readonly commentCount: "{{count}} 条评论";
3062
+ readonly commentCountOne: "{{count}} 条评论";
3063
+ readonly resolvedSuffix: " · 已解决";
3064
+ readonly sortComments: "评论排序";
3065
+ readonly sortOldest: "最早优先";
3066
+ readonly sortNewest: "最新优先";
3067
+ readonly resolve: "标记已解决";
3068
+ readonly reopen: "重新打开";
3069
+ readonly justNow: "刚刚";
3070
+ readonly minutesAgo: "{{count}} 分钟前";
3071
+ readonly hoursAgo: "{{count}} 小时前";
3072
+ readonly daysAgo: "{{count}} 天前";
3073
+ readonly edited: "(已编辑)";
3074
+ readonly reactionCount: "{{count}} 个回应";
3075
+ readonly reactionCountOne: "{{count}} 个回应";
3076
+ readonly addThumbsUp: "点赞";
3077
+ readonly reply: "回复";
3078
+ readonly reactThumbsUp: "以点赞回应";
3079
+ readonly reactHeart: "以爱心回应";
3080
+ readonly cancelReply: "取消回复";
3081
+ readonly replyingTo: "正在回复 {{name}}…";
3082
+ readonly replyingToComment: "正在回复该评论…";
3083
+ readonly commentPlaceholder: "添加评论…(输入 @ 提及他人)";
3084
+ readonly send: "发送";
3085
+ readonly presentUserCount: "{{count}} 人在线";
3086
+ readonly presentUserCountOne: "{{count}} 人在线";
3087
+ readonly moreUserCount: "另有 {{count}} 人";
3088
+ readonly moreUserCountOne: "另有 {{count}} 人";
3089
+ readonly userStatusTitle: "{{name}}({{status}})";
3090
+ readonly statusActive: "活跃";
3091
+ readonly statusIdle: "空闲";
3092
+ readonly statusAway: "离开";
3093
+ };
3010
3094
  };
3011
3095
  export default zh;
@@ -84,6 +84,10 @@ const zh = {
84
84
  select: '选择...',
85
85
  openChat: '打开聊天',
86
86
  closeChat: '关闭聊天',
87
+ closePanel: '关闭面板',
88
+ resizeDrawer: '调整面板宽度',
89
+ itemCount: '{{count}} 项',
90
+ itemCountOne: '{{count}} 项',
87
91
  toggleSidebar: '切换侧边栏',
88
92
  package: '软件包',
89
93
  },
@@ -108,6 +112,7 @@ const zh = {
108
112
  url: '请输入有效的URL',
109
113
  pattern: '{{field}}格式不正确',
110
114
  formInvalid: '请检查表单中标记的字段:{{fields}}',
115
+ formInvalidJoiner: '、',
111
116
  unique: '{{field}}必须唯一',
112
117
  type: '{{field}}必须是有效的{{type}}',
113
118
  },
@@ -132,12 +137,21 @@ const zh = {
132
137
  stepOf: '第{{current}}步,共{{total}}步',
133
138
  createTitle: '新建{{object}}',
134
139
  editTitle: '编辑{{object}}',
140
+ viewTitle: '查看{{object}}',
141
+ createTargetOrg: '创建到{{org}}',
135
142
  saveRecord: '保存',
136
143
  create: '创建',
137
144
  update: '更新',
138
145
  createSuccess: '{{object}}创建成功',
139
146
  updateSuccess: '{{object}}更新成功',
140
147
  deleteSuccess: '{{object}}删除成功',
148
+ fullscreen: {
149
+ title: '编辑文本',
150
+ description: '编辑完整的文本内容,然后保存或取消更改。',
151
+ done: '完成',
152
+ toggle: '全屏编辑{{label}}',
153
+ textFallback: '文本',
154
+ },
141
155
  },
142
156
  fields: {
143
157
  relativeDate: {
@@ -177,6 +191,10 @@ const zh = {
177
191
  basicEditorHint: '富文本编辑器(基础)',
178
192
  placeholder: '请输入文字...',
179
193
  },
194
+ options: {
195
+ empty: '暂无可选项',
196
+ selectFirst: '请先选择{{fields}}',
197
+ },
180
198
  objectRef: {
181
199
  loading: '正在加载对象…',
182
200
  placeholder: '请选择对象',
@@ -204,6 +222,13 @@ const zh = {
204
222
  editAsJson: '以 JSON 编辑',
205
223
  useVisualBuilder: '使用可视化构建器',
206
224
  },
225
+ tags: {
226
+ placeholder: '输入后回车添加…',
227
+ },
228
+ textarea: {
229
+ characterCount: '已输入 {{count}} 个字符,最多 {{max}} 个',
230
+ charactersRemaining: '还可输入 {{count}} 个字符',
231
+ },
207
232
  },
208
233
  table: {
209
234
  rowsPerPage: '每页行数',
@@ -678,6 +703,12 @@ const zh = {
678
703
  over: '超载',
679
704
  empty: '没有可分配的任务。',
680
705
  },
706
+ quickFilter: {
707
+ all: '全部',
708
+ clear: '清除筛选',
709
+ empty: '无可选项',
710
+ resultSummary: '显示 {shown} / {total} 项任务',
711
+ },
681
712
  readOnly: '只读',
682
713
  readOnlyHint: '此视图已禁用编辑。',
683
714
  lockedHint: '无编辑权限',
@@ -728,14 +759,15 @@ const zh = {
728
759
  editInlineHint: '双击编辑',
729
760
  lockedByApproval: '审批中已锁定',
730
761
  lockedTooltip: '该记录有待审批的请求,编辑已被锁定',
731
- writeStrippedTitle: '部分字段未保存',
732
- writeStrippedReadonly: '以下字段为只读,未保存:{{fields}}',
733
- writeStrippedByState: '以下字段在记录当前状态下不可编辑,未保存:{{fields}}',
762
+ writeStrippedTitle: '已保存,但部分字段未生效',
763
+ writeStrippedReadonly: '以下字段为只读,未生效:{{fields}}',
764
+ writeStrippedByState: '以下字段在记录当前状态下不可编辑,未生效:{{fields}}',
734
765
  approvalPendingEditable: '审批中 · 可编辑',
735
766
  approvalPendingTooltip: '该记录有待审批的请求,但当前审批节点仍允许编辑',
736
767
  approvalProgress: '审批 — 已通过 {{got}} / 共需 {{need}}',
737
768
  approvalProgressGroups: '会签 — 已完成 {{got}} / 共 {{need}} 个组',
738
769
  approvalProgressLabel: '审批进度',
770
+ approvalsPanelTitle: '审批',
739
771
  cancelApproval: '撤回审批',
740
772
  cancelApprovalInFlight: '撤回中…',
741
773
  cancelApprovalTooltip: '撤回当前的待审批请求以解除记录锁定',
@@ -764,6 +796,15 @@ const zh = {
764
796
  viewHistory: '查看历史',
765
797
  delete: '删除',
766
798
  moreActions: '更多操作',
799
+ addReaction: '添加表情回应',
800
+ pageHeaderActions: '页面标题栏操作',
801
+ emojiPicker: '表情选择器',
802
+ reactionCount: '{{emoji}} {{count}} 个回应',
803
+ reactionCountOne: '{{emoji}} {{count}} 个回应',
804
+ recordDetail: '记录详情',
805
+ recordDetailWithLabel: '{{label}}详情',
806
+ openAsFullPage: '以完整页面打开',
807
+ recordDetailOverlay: '{{title}} 的记录详情浮层。',
767
808
  addToFavorites: '添加到收藏',
768
809
  removeFromFavorites: '从收藏中移除',
769
810
  previousRecord: '上一条记录',
@@ -1407,6 +1448,13 @@ const zh = {
1407
1448
  retry: '重试',
1408
1449
  retrying: '正在重试…',
1409
1450
  },
1451
+ serverAction: {
1452
+ openingTitle: '正在打开…',
1453
+ openingBody: '正在打开…可能需要一点时间。',
1454
+ popupBlockedTitle: '浏览器拦截了弹窗',
1455
+ popupBlockedDescription: '浏览器阻止了新标签页的打开。',
1456
+ popupBlockedAction: '在新标签页打开',
1457
+ },
1410
1458
  shortcuts: {
1411
1459
  title: '键盘快捷键',
1412
1460
  description: '所有可用键盘快捷键的快速参考。',
@@ -1652,6 +1700,8 @@ const zh = {
1652
1700
  objectNotFound: '未找到对象',
1653
1701
  objectNotFoundDescription: '对象"{{objectName}}"在当前配置中不存在。',
1654
1702
  objectNotFoundHint: '请检查您的应用导航设置或从侧边栏选择其他对象。',
1703
+ filterOrNotSavable: '该筛选条件之间使用「或」,保存到视图暂不支持这种结构。它仍会作用于当前列表 —— 取消「或」分组后才能保存到视图。',
1704
+ filterNestedNotSavable: '该筛选使用了嵌套条件分组,保存到视图不支持这种结构。请改为单层条件列表后再保存到视图。',
1655
1705
  allRecords: '所有记录',
1656
1706
  exitDesignMode: '退出设计模式',
1657
1707
  enterDesignMode: '进入设计模式',
@@ -3049,5 +3099,39 @@ const zh = {
3049
3099
  addSort: '添加排序',
3050
3100
  removeSort: '删除排序',
3051
3101
  },
3102
+ collaboration: {
3103
+ commentCount: '{{count}} 条评论',
3104
+ commentCountOne: '{{count}} 条评论',
3105
+ resolvedSuffix: ' · 已解决',
3106
+ sortComments: '评论排序',
3107
+ sortOldest: '最早优先',
3108
+ sortNewest: '最新优先',
3109
+ resolve: '标记已解决',
3110
+ reopen: '重新打开',
3111
+ justNow: '刚刚',
3112
+ minutesAgo: '{{count}} 分钟前',
3113
+ hoursAgo: '{{count}} 小时前',
3114
+ daysAgo: '{{count}} 天前',
3115
+ edited: '(已编辑)',
3116
+ reactionCount: '{{count}} 个回应',
3117
+ reactionCountOne: '{{count}} 个回应',
3118
+ addThumbsUp: '点赞',
3119
+ reply: '回复',
3120
+ reactThumbsUp: '以点赞回应',
3121
+ reactHeart: '以爱心回应',
3122
+ cancelReply: '取消回复',
3123
+ replyingTo: '正在回复 {{name}}…',
3124
+ replyingToComment: '正在回复该评论…',
3125
+ commentPlaceholder: '添加评论…(输入 @ 提及他人)',
3126
+ send: '发送',
3127
+ presentUserCount: '{{count}} 人在线',
3128
+ presentUserCountOne: '{{count}} 人在线',
3129
+ moreUserCount: '另有 {{count}} 人',
3130
+ moreUserCountOne: '另有 {{count}} 人',
3131
+ userStatusTitle: '{{name}}({{status}})',
3132
+ statusActive: '活跃',
3133
+ statusIdle: '空闲',
3134
+ statusAway: '离开',
3135
+ },
3052
3136
  };
3053
3137
  export default zh;
@@ -6,6 +6,28 @@
6
6
  import React from 'react';
7
7
  import type { i18n as I18nInstance } from 'i18next';
8
8
  import { type I18nConfig } from './i18n';
9
+ /**
10
+ * `localStorage` key holding the user's explicit language choice.
11
+ *
12
+ * Follows the console's existing preference-key convention
13
+ * (`objectui-favorites`, `objectui-recent-items`), and sits beside the theme
14
+ * preference the same avatar menu writes — a language switch is a preference,
15
+ * not ephemeral UI state, so it belongs in storage (AGENTS.md §5 #8).
16
+ *
17
+ * Exported so an app that builds its own i18next instance (and therefore owns
18
+ * its bootstrap language — see {@link I18nProviderProps.instance}) can honour
19
+ * the same preference, and so a sign-out flow can clear it.
20
+ */
21
+ export declare const LOCALE_STORAGE_KEY = "objectui-locale";
22
+ /**
23
+ * Read the persisted language choice, or `null` when nothing is stored.
24
+ *
25
+ * Defensive by design: `localStorage` does not exist under SSR and *throws* on
26
+ * access in Safari private mode / partitioned iframes. A language preference is
27
+ * never worth taking the app down for, so every failure degrades to "nothing
28
+ * stored".
29
+ */
30
+ export declare function readStoredLanguage(): string | null;
9
31
  interface I18nContextValue {
10
32
  /** Current language code */
11
33
  language: string;
@@ -39,6 +61,26 @@ export interface I18nProviderProps {
39
61
  * ```
40
62
  */
41
63
  loadLanguage?: (lang: string) => Promise<Record<string, unknown>>;
64
+ /**
65
+ * Remember the active language in `localStorage` ({@link LOCALE_STORAGE_KEY})
66
+ * and restore it on the next mount. Default: `true`.
67
+ *
68
+ * Every language change on the instance is persisted — whether it came from
69
+ * this provider's `changeLanguage`, a switcher calling
70
+ * `i18n.changeLanguage()` directly, or app code — because the promise the UI
71
+ * makes ("this is my language now") must not depend on which API the caller
72
+ * reached for.
73
+ *
74
+ * The *restore* half only applies to an instance this provider creates: when
75
+ * you pass your own {@link I18nProviderProps.instance}, its bootstrap
76
+ * language is yours to choose (call {@link readStoredLanguage} if you want
77
+ * the same preference honoured).
78
+ *
79
+ * Set `false` for surfaces that must stay on a fixed language regardless of
80
+ * what the user picked elsewhere on the origin — previews, demos, screenshot
81
+ * harnesses.
82
+ */
83
+ persistLanguage?: boolean;
42
84
  /** Children to render */
43
85
  children: React.ReactNode;
44
86
  }
@@ -52,7 +94,7 @@ export interface I18nProviderProps {
52
94
  * </I18nProvider>
53
95
  * ```
54
96
  */
55
- export declare function I18nProvider({ config, instance: externalInstance, loadLanguage, children }: I18nProviderProps): React.FunctionComponentElement<React.ProviderProps<I18nContextValue | null>>;
97
+ export declare function I18nProvider({ config, instance: externalInstance, loadLanguage, persistLanguage, children, }: I18nProviderProps): React.FunctionComponentElement<React.ProviderProps<I18nContextValue | null>>;
56
98
  /**
57
99
  * Hook to access Object UI i18n context
58
100
  *
package/dist/provider.js CHANGED
@@ -6,6 +6,100 @@
6
6
  import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
7
7
  import { I18nextProvider, useTranslation } from 'react-i18next';
8
8
  import { createI18n, getDirection } from './i18n';
9
+ import { builtInLocales } from './locales/index';
10
+ /**
11
+ * `localStorage` key holding the user's explicit language choice.
12
+ *
13
+ * Follows the console's existing preference-key convention
14
+ * (`objectui-favorites`, `objectui-recent-items`), and sits beside the theme
15
+ * preference the same avatar menu writes — a language switch is a preference,
16
+ * not ephemeral UI state, so it belongs in storage (AGENTS.md §5 #8).
17
+ *
18
+ * Exported so an app that builds its own i18next instance (and therefore owns
19
+ * its bootstrap language — see {@link I18nProviderProps.instance}) can honour
20
+ * the same preference, and so a sign-out flow can clear it.
21
+ */
22
+ export const LOCALE_STORAGE_KEY = 'objectui-locale';
23
+ /**
24
+ * Read the persisted language choice, or `null` when nothing is stored.
25
+ *
26
+ * Defensive by design: `localStorage` does not exist under SSR and *throws* on
27
+ * access in Safari private mode / partitioned iframes. A language preference is
28
+ * never worth taking the app down for, so every failure degrades to "nothing
29
+ * stored".
30
+ */
31
+ export function readStoredLanguage() {
32
+ if (typeof window === 'undefined')
33
+ return null;
34
+ try {
35
+ return window.localStorage.getItem(LOCALE_STORAGE_KEY);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function writeStoredLanguage(lang) {
42
+ if (typeof window === 'undefined')
43
+ return;
44
+ try {
45
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, lang);
46
+ }
47
+ catch {
48
+ // Storage blocked or full — the switch still applies for this session.
49
+ }
50
+ }
51
+ function clearStoredLanguage() {
52
+ if (typeof window === 'undefined')
53
+ return;
54
+ try {
55
+ window.localStorage.removeItem(LOCALE_STORAGE_KEY);
56
+ }
57
+ catch {
58
+ // Nothing readable means nothing to purge.
59
+ }
60
+ }
61
+ /**
62
+ * Languages `createI18n(config)` will know about: the built-in packs plus any
63
+ * extra `config.resources`.
64
+ *
65
+ * `hasOwnProperty` rather than `in`: a junk stored value like `constructor`
66
+ * would pass an `in` check against the locale map's prototype.
67
+ */
68
+ function isKnownLanguage(lang, config) {
69
+ const own = Object.prototype.hasOwnProperty;
70
+ return own.call(builtInLocales, lang) || Boolean(config?.resources && own.call(config.resources, lang));
71
+ }
72
+ /**
73
+ * Resolve the bootstrap config for a provider-owned i18next instance, applying
74
+ * a stored language choice when there is a usable one.
75
+ *
76
+ * Applied at instance creation rather than through a post-mount
77
+ * `changeLanguage`, because the very first render must already be in the right
78
+ * locale: `<html lang>` seeds the `Accept-Language` header on every API call
79
+ * (`createAuthenticatedFetch`, issue #1319), so a late switch would fetch the
80
+ * first wave of server-resolved metadata labels in the wrong language and then
81
+ * have to remount to fix it.
82
+ *
83
+ * A stored choice outranks browser detection (which itself outranks
84
+ * `defaultLanguage` in `createI18n`) — otherwise a user who picked 中文 on a
85
+ * `ja` browser would be handed `ja` back on every reload, and the preference
86
+ * would be unusable for exactly the users who need it.
87
+ *
88
+ * A stored value the app no longer offers is dropped *and purged*, so one stale
89
+ * entry can never lock the UI to a locale that has no translations.
90
+ */
91
+ function resolveBootstrapConfig(config, persist) {
92
+ if (!persist)
93
+ return config;
94
+ const stored = readStoredLanguage();
95
+ if (!stored)
96
+ return config;
97
+ if (!isKnownLanguage(stored, config)) {
98
+ clearStoredLanguage();
99
+ return config;
100
+ }
101
+ return { ...config, defaultLanguage: stored, detectBrowserLanguage: false };
102
+ }
9
103
  const ObjectI18nContext = createContext(null);
10
104
  /**
11
105
  * I18nProvider - Wraps your app with i18n support
@@ -17,8 +111,8 @@ const ObjectI18nContext = createContext(null);
17
111
  * </I18nProvider>
18
112
  * ```
19
113
  */
20
- export function I18nProvider({ config, instance: externalInstance, loadLanguage, children }) {
21
- const i18nInstance = useMemo(() => externalInstance || createI18n(config), [externalInstance, config]);
114
+ export function I18nProvider({ config, instance: externalInstance, loadLanguage, persistLanguage = true, children, }) {
115
+ const i18nInstance = useMemo(() => externalInstance || createI18n(resolveBootstrapConfig(config, persistLanguage)), [externalInstance, config, persistLanguage]);
22
116
  const [language, setLanguage] = useState(i18nInstance.language || 'en');
23
117
  const direction = getDirection(language);
24
118
  // Track which languages have had app-specific translations loaded
@@ -27,6 +121,14 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
27
121
  useEffect(() => {
28
122
  const handleLanguageChanged = (lng) => {
29
123
  setLanguage(lng);
124
+ // Remember the choice. This is the single choke point every switch runs
125
+ // through — i18next fires `languageChanged` for the context's
126
+ // `changeLanguage` and for a direct `i18n.changeLanguage()` alike — so no
127
+ // switcher can be wired in a way that silently forgets the preference.
128
+ // Bootstrap does NOT fire this event, so restoring a stored language
129
+ // never re-writes it.
130
+ if (persistLanguage)
131
+ writeStoredLanguage(lng);
30
132
  // Update document direction for RTL support
31
133
  if (typeof document !== 'undefined') {
32
134
  document.documentElement.dir = getDirection(lng);
@@ -49,7 +151,7 @@ export function I18nProvider({ config, instance: externalInstance, loadLanguage,
49
151
  return () => {
50
152
  i18nInstance.off('languageChanged', handleLanguageChanged);
51
153
  };
52
- }, [i18nInstance]);
154
+ }, [i18nInstance, persistLanguage]);
53
155
  // Load app-specific translations for the initial language on mount
54
156
  useEffect(() => {
55
157
  if (!loadLanguage)
@@ -17,7 +17,23 @@ export function createSafeTranslation(defaults, testKey) {
17
17
  let value = defaults[key] || key;
18
18
  if (options) {
19
19
  for (const [k, v] of Object.entries(options)) {
20
- value = value.replace(`{{${k}}}`, String(v));
20
+ // `split(needle).join(value)` — deliberately not `replace`, and not
21
+ // `replaceAll` either (objectui#3418). This path must agree with
22
+ // i18next, which serves the *provider* path; any divergence is a
23
+ // silent fork that only shows up on provider-less hosts, where we are
24
+ // least likely to see it:
25
+ // 1. `replace` with a string needle substitutes only the FIRST
26
+ // occurrence. i18next substitutes every one, so a sentence that
27
+ // repeats a placeholder ("Selected {{count}} of {{count}} items"
28
+ // — natural in many locales, and often required by RTL /
29
+ // agglutinative word order) leaked literal braces to users.
30
+ // 2. `replace` AND `replaceAll` both interpret `$&`, `` $` ``, `$'`
31
+ // and `$$` in the *replacement* string. i18next does not. Values
32
+ // here are runtime data — record labels, search terms — so this
33
+ // one is reachable today, unlike (1).
34
+ // split/join is literal on both sides, which is exactly i18next's
35
+ // behaviour, and needs no regex escaping of the placeholder name.
36
+ value = value.split(`{{${k}}}`).join(String(v));
21
37
  }
22
38
  }
23
39
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/i18n",
3
- "version": "17.2.0",
3
+ "version": "17.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "react": "^18.0.0 || ^19.0.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/react": "19.2.17",
40
+ "@types/react": "19.2.18",
41
41
  "react": "19.2.8",
42
42
  "typescript": "^6.0.3",
43
43
  "vitest": "^4.1.10"