@object-ui/i18n 4.3.0 → 4.5.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.
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * @module useObjectLabel
18
18
  */
19
+ import { useMemo } from 'react';
19
20
  import { useObjectTranslation } from './provider';
20
21
  /**
21
22
  * Built-in Object UI top-level locale keys — not app namespaces.
@@ -45,282 +46,299 @@ const BUILTIN_KEYS = new Set([
45
46
  */
46
47
  export function useObjectLabel() {
47
48
  const { t, i18n } = useObjectTranslation();
48
- /**
49
- * Discover app namespace(s) from loaded i18next resources.
50
- * Returns top-level keys (outside built-in Object UI keys) that contain
51
- * an `objects`, `fields`, or `apps` sub-key — e.g. "crm" when resources
52
- * include crm.objects.* or crm.apps.*.
53
- */
54
- const getAppNamespaces = () => {
55
- if (!i18n || typeof i18n.getResourceBundle !== 'function')
56
- return [];
57
- const lang = i18n.language || 'en';
58
- const bundle = i18n.getResourceBundle(lang, 'translation');
59
- if (!bundle)
60
- return [];
61
- return Object.keys(bundle).filter((key) => !BUILTIN_KEYS.has(key) && bundle[key] && typeof bundle[key] === 'object'
62
- && (bundle[key].objects || bundle[key].fields || bundle[key].apps
63
- || bundle[key].dashboards || bundle[key].pages || bundle[key].reports));
64
- };
65
- /**
66
- * Strip an ObjectStack namespace prefix (e.g. `crm__lead` → `lead`) so that
67
- * translations authored against short object names still resolve when the
68
- * runtime presents fully-qualified names. The first `__` separates the
69
- * package namespace from the base name; everything after is preserved.
70
- */
71
- const stripNamespace = (name) => {
72
- const idx = name.indexOf('__');
73
- return idx > 0 ? name.slice(idx + 2) : name;
74
- };
75
- /** Try resolving a key across all discovered app namespaces. */
76
- const resolve = (suffixes, fallback) => {
77
- const suffixList = Array.isArray(suffixes) ? suffixes : [suffixes];
78
- try {
79
- const namespaces = getAppNamespaces();
80
- for (const ns of namespaces) {
81
- for (const suffix of suffixList) {
82
- const key = `${ns}.${suffix}`;
83
- const translated = t(key, { defaultValue: '' });
84
- if (translated && translated !== key && translated !== '') {
85
- return translated;
86
- }
87
- }
88
- }
89
- }
90
- catch {
91
- // Graceful degradation when i18n provider is not available
92
- }
93
- return fallback;
94
- };
95
- /** Build suffix candidates: prefer the given name, fall back to the base (unprefixed) name. */
96
- const objectSuffixes = (objectName, tail) => {
97
- const base = stripNamespace(objectName);
98
- return base !== objectName
99
- ? [`objects.${objectName}.${tail}`, `objects.${base}.${tail}`]
100
- : [`objects.${objectName}.${tail}`];
101
- };
102
- const fieldSuffixes = (objectName, fieldName) => {
103
- const base = stripNamespace(objectName);
104
- return base !== objectName
105
- ? [`fields.${objectName}.${fieldName}`, `fields.${base}.${fieldName}`]
106
- : [`fields.${objectName}.${fieldName}`];
107
- };
108
- const optionSuffixes = (objectName, fieldName, optionValue) => {
109
- const base = stripNamespace(objectName);
110
- return base !== objectName
111
- ? [
112
- `fieldOptions.${objectName}.${fieldName}.${optionValue}`,
113
- `fieldOptions.${base}.${fieldName}.${optionValue}`,
114
- ]
115
- : [`fieldOptions.${objectName}.${fieldName}.${optionValue}`];
116
- };
117
- /**
118
- * Build suffix candidates for a dashboard-scoped key. Mirrors the
119
- * object/field convention: prefer the (possibly namespaced) dashboard name,
120
- * fall back to the unprefixed base name.
121
- */
122
- const dashboardSuffixes = (dashboardName, tail) => {
123
- const base = stripNamespace(dashboardName);
124
- return base !== dashboardName
125
- ? [`dashboards.${dashboardName}.${tail}`, `dashboards.${base}.${tail}`]
126
- : [`dashboards.${dashboardName}.${tail}`];
127
- };
128
- /**
129
- * Build suffix candidates for a page-scoped key.
130
- */
131
- const pageSuffixes = (pageName, tail) => {
132
- const base = stripNamespace(pageName);
133
- return base !== pageName
134
- ? [`pages.${pageName}.${tail}`, `pages.${base}.${tail}`]
135
- : [`pages.${pageName}.${tail}`];
136
- };
137
- /**
138
- * Build suffix candidates for a report-scoped key.
139
- */
140
- const reportSuffixes = (reportName, tail) => {
141
- const base = stripNamespace(reportName);
142
- return base !== reportName
143
- ? [`reports.${reportName}.${tail}`, `reports.${base}.${tail}`]
144
- : [`reports.${reportName}.${tail}`];
145
- };
146
- return {
147
- /**
148
- * Resolve translated object label, falling back to objectDef.label.
149
- */
150
- objectLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label),
151
- /**
152
- * Resolve translated object description, falling back to objectDef.description.
153
- */
154
- objectDescription: (objectDef) => {
155
- if (!objectDef.description)
156
- return undefined;
157
- return resolve(objectSuffixes(objectDef.name, 'description'), objectDef.description);
158
- },
159
- /**
160
- * Resolve translated field label, falling back to the provided fallback string.
161
- */
162
- fieldLabel: (objectName, fieldName, fallback) => resolve(fieldSuffixes(objectName, fieldName), fallback),
163
- /**
164
- * Resolve a translated select option label for a given object field.
165
- * Falls back to the provided fallback (usually the English option label).
166
- */
167
- fieldOptionLabel: (objectName, fieldName, optionValue, fallback) => resolve(optionSuffixes(objectName, fieldName, optionValue), fallback),
168
- /**
169
- * Translate all options for a given field, returning a new options array
170
- * with translated labels. Pass the objectName and fieldName to look up
171
- * translations; the original label is used as fallback.
172
- */
173
- translateOptions: (objectName, fieldName, options) => options.map(opt => ({
174
- ...opt,
175
- label: resolve(optionSuffixes(objectName, fieldName, opt.value), opt.label),
176
- })),
177
- /**
178
- * Resolve translated app label, falling back to appDef.label.
179
- * Looks up `{ns}.apps.{appName}.label` from loaded i18next resources.
180
- */
181
- appLabel: (appDef) => resolve(`apps.${appDef.name}.label`, appDef.label ?? appDef.name),
182
- /**
183
- * Resolve translated app description, falling back to appDef.description.
184
- * Returns the translated value even when metadata has no description —
185
- * translation-only descriptions (defined only in i18n bundles) are common
186
- * in examples where the app metadata is English-only.
187
- */
188
- appDescription: (appDef) => {
189
- const fallback = appDef.description ?? '';
190
- const resolved = resolve(`apps.${appDef.name}.description`, fallback);
191
- return resolved || undefined;
192
- },
193
- /**
194
- * Resolve translated label for a navigation group within an app.
195
- * Convention: `{ns}.apps.{appName}.navigation.{groupId}.label`.
196
- *
197
- * Mirrors `objectLabel`/`dashboardLabel` so app metadata can keep
198
- * English fallbacks while translation packs supply localised
199
- * sidebar group labels (e.g. "Sales" → "销售") without explicit
200
- * I18nLabel `{ key, defaultValue }` annotations.
201
- */
202
- navGroupLabel: (appName, groupId, fallback) => resolve(`apps.${appName}.navigation.${groupId}.label`, fallback),
203
- /**
204
- * Resolve translated dashboard label, falling back to dashboardDef.label.
205
- * Convention: `{ns}.dashboards.{dashboardName}.label`.
206
- */
207
- dashboardLabel: (dashboardDef) => resolve(dashboardSuffixes(dashboardDef.name, 'label'), dashboardDef.label ?? dashboardDef.name),
208
- /**
209
- * Resolve translated dashboard description, falling back to
210
- * dashboardDef.description. Returns undefined when neither metadata nor
211
- * translation provides one.
212
- * Convention: `{ns}.dashboards.{dashboardName}.description`.
213
- */
214
- dashboardDescription: (dashboardDef) => {
215
- const fallback = dashboardDef.description ?? '';
216
- const resolved = resolve(dashboardSuffixes(dashboardDef.name, 'description'), fallback);
217
- return resolved || undefined;
218
- },
219
- /**
220
- * Resolve translated dashboard header-action label.
221
- * Convention: `{ns}.dashboards.{dashboardName}.actions.{actionKey}.label`.
222
- * The actionKey is typically the action's `actionUrl` (e.g.
223
- * `create_opportunity`) or its English label slugified.
224
- */
225
- dashboardActionLabel: (dashboardName, actionKey, fallback) => resolve(dashboardSuffixes(dashboardName, `actions.${actionKey}.label`), fallback),
226
- /**
227
- * Resolve translated widget title within a dashboard.
228
- * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.title`.
229
- */
230
- widgetTitle: (dashboardName, widgetId, fallback) => resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.title`), fallback),
231
- /**
232
- * Resolve translated widget description within a dashboard.
233
- * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
234
- * Returns undefined when neither metadata nor translation provides one.
235
- */
236
- widgetDescription: (dashboardName, widgetId, fallback) => {
237
- const fb = fallback ?? '';
238
- const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
239
- return resolved || undefined;
240
- },
241
- /**
242
- * Resolve translated page label, falling back to pageDef.label.
243
- * Convention: `{ns}.pages.{pageName}.label`.
244
- */
245
- pageLabel: (pageDef) => resolve(pageSuffixes(pageDef.name, 'label'), pageDef.label ?? pageDef.name),
246
- /**
247
- * Resolve translated report label, falling back to reportDef.label.
248
- * Convention: `{ns}.reports.{reportName}.label`.
249
- */
250
- reportLabel: (reportDef) => resolve(reportSuffixes(reportDef.name, 'label'), reportDef.label ?? reportDef.name),
251
- /**
252
- * Resolve translated list-view label.
253
- * Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`.
254
- */
255
- viewLabel: (objectName, viewName, fallback) => resolve(objectSuffixes(objectName, `_views.${viewName}.label`), fallback),
49
+ // Memoize the entire returned object — all closures below reference `t`/`i18n`
50
+ // and stay valid until the language changes. Returning a fresh object on every
51
+ // render was busting downstream `useMemo`/`useCallback` deps in heavy consumers
52
+ // like ListView.filterFields, causing avoidable recomputation.
53
+ return useMemo(() => {
256
54
  /**
257
- * Resolve translated list-view description.
258
- * Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
55
+ * Discover app namespace(s) from loaded i18next resources.
56
+ * Returns top-level keys (outside built-in Object UI keys) that contain
57
+ * an `objects`, `fields`, or `apps` sub-key — e.g. "crm" when resources
58
+ * include crm.objects.* or crm.apps.*.
259
59
  */
260
- viewDescription: (objectName, viewName, fallback) => {
261
- const fb = fallback ?? '';
262
- const resolved = resolve(objectSuffixes(objectName, `_views.${viewName}.description`), fb);
263
- return resolved || undefined;
264
- },
60
+ const getAppNamespaces = () => {
61
+ if (!i18n || typeof i18n.getResourceBundle !== 'function')
62
+ return [];
63
+ const lang = i18n.language || 'en';
64
+ const bundle = i18n.getResourceBundle(lang, 'translation');
65
+ if (!bundle)
66
+ return [];
67
+ return Object.keys(bundle).filter((key) => !BUILTIN_KEYS.has(key) && bundle[key] && typeof bundle[key] === 'object'
68
+ && (bundle[key].objects || bundle[key].fields || bundle[key].apps
69
+ || bundle[key].dashboards || bundle[key].pages || bundle[key].reports));
70
+ };
265
71
  /**
266
- * Resolve translated form-section label.
267
- * Convention: `{ns}.objects.{objectName}._sections.{sectionName}.label`.
72
+ * Strip an ObjectStack namespace prefix (e.g. `crm__lead` → `lead`) so that
73
+ * translations authored against short object names still resolve when the
74
+ * runtime presents fully-qualified names. The first `__` separates the
75
+ * package namespace from the base name; everything after is preserved.
268
76
  */
269
- sectionLabel: (objectName, sectionName, fallback) => resolve(objectSuffixes(objectName, `_sections.${sectionName}.label`), fallback),
77
+ const stripNamespace = (name) => {
78
+ const idx = name.indexOf('__');
79
+ return idx > 0 ? name.slice(idx + 2) : name;
80
+ };
81
+ /** Try resolving a key across all discovered app namespaces. */
82
+ const resolve = (suffixes, fallback) => {
83
+ const suffixList = Array.isArray(suffixes) ? suffixes : [suffixes];
84
+ try {
85
+ const namespaces = getAppNamespaces();
86
+ for (const ns of namespaces) {
87
+ for (const suffix of suffixList) {
88
+ const key = `${ns}.${suffix}`;
89
+ const translated = t(key, { defaultValue: '' });
90
+ if (translated && translated !== key && translated !== '') {
91
+ return translated;
92
+ }
93
+ }
94
+ }
95
+ }
96
+ catch {
97
+ // Graceful degradation when i18n provider is not available
98
+ }
99
+ return fallback;
100
+ };
101
+ /** Build suffix candidates: prefer the given name, fall back to the base (unprefixed) name. */
102
+ const objectSuffixes = (objectName, tail) => {
103
+ const base = stripNamespace(objectName);
104
+ return base !== objectName
105
+ ? [`objects.${objectName}.${tail}`, `objects.${base}.${tail}`]
106
+ : [`objects.${objectName}.${tail}`];
107
+ };
108
+ const fieldSuffixes = (objectName, fieldName) => {
109
+ const base = stripNamespace(objectName);
110
+ return base !== objectName
111
+ ? [`fields.${objectName}.${fieldName}`, `fields.${base}.${fieldName}`]
112
+ : [`fields.${objectName}.${fieldName}`];
113
+ };
114
+ const optionSuffixes = (objectName, fieldName, optionValue) => {
115
+ const base = stripNamespace(objectName);
116
+ return base !== objectName
117
+ ? [
118
+ `fieldOptions.${objectName}.${fieldName}.${optionValue}`,
119
+ `fieldOptions.${base}.${fieldName}.${optionValue}`,
120
+ ]
121
+ : [`fieldOptions.${objectName}.${fieldName}.${optionValue}`];
122
+ };
270
123
  /**
271
- * Resolve translated action label.
272
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`.
273
- * Falls back to `{ns}.globalActions.{actionName}.label` when objectName is omitted.
124
+ * Build suffix candidates for a dashboard-scoped key. Mirrors the
125
+ * object/field convention: prefer the (possibly namespaced) dashboard name,
126
+ * fall back to the unprefixed base name.
274
127
  */
275
- actionLabel: (objectName, actionName, fallback) => {
276
- if (objectName) {
277
- return resolve(objectSuffixes(objectName, `_actions.${actionName}.label`), fallback);
278
- }
279
- return resolve(`globalActions.${actionName}.label`, fallback);
280
- },
128
+ const dashboardSuffixes = (dashboardName, tail) => {
129
+ const base = stripNamespace(dashboardName);
130
+ return base !== dashboardName
131
+ ? [`dashboards.${dashboardName}.${tail}`, `dashboards.${base}.${tail}`]
132
+ : [`dashboards.${dashboardName}.${tail}`];
133
+ };
281
134
  /**
282
- * Resolve translated action confirmation prompt.
283
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`.
284
- * Returns undefined when no translation and no fallback exist.
135
+ * Build suffix candidates for a page-scoped key.
285
136
  */
286
- actionConfirm: (objectName, actionName, fallback) => {
287
- const fb = fallback ?? '';
288
- const suffixes = objectName
289
- ? objectSuffixes(objectName, `_actions.${actionName}.confirmText`)
290
- : `globalActions.${actionName}.confirmText`;
291
- const resolved = resolve(suffixes, fb);
292
- return resolved || undefined;
293
- },
137
+ const pageSuffixes = (pageName, tail) => {
138
+ const base = stripNamespace(pageName);
139
+ return base !== pageName
140
+ ? [`pages.${pageName}.${tail}`, `pages.${base}.${tail}`]
141
+ : [`pages.${pageName}.${tail}`];
142
+ };
294
143
  /**
295
- * Resolve translated action success message.
296
- * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`.
144
+ * Build suffix candidates for a report-scoped key.
297
145
  */
298
- actionSuccess: (objectName, actionName, fallback) => {
299
- const fb = fallback ?? '';
300
- const suffixes = objectName
301
- ? objectSuffixes(objectName, `_actions.${actionName}.successMessage`)
302
- : `globalActions.${actionName}.successMessage`;
303
- const resolved = resolve(suffixes, fb);
304
- return resolved || undefined;
305
- },
306
- };
146
+ const reportSuffixes = (reportName, tail) => {
147
+ const base = stripNamespace(reportName);
148
+ return base !== reportName
149
+ ? [`reports.${reportName}.${tail}`, `reports.${base}.${tail}`]
150
+ : [`reports.${reportName}.${tail}`];
151
+ };
152
+ return {
153
+ /**
154
+ * Resolve translated object label, falling back to objectDef.label.
155
+ */
156
+ objectLabel: (objectDef) => resolve(objectSuffixes(objectDef.name, 'label'), objectDef.label),
157
+ /**
158
+ * Resolve translated object description, falling back to objectDef.description.
159
+ */
160
+ objectDescription: (objectDef) => {
161
+ if (!objectDef.description)
162
+ return undefined;
163
+ return resolve(objectSuffixes(objectDef.name, 'description'), objectDef.description);
164
+ },
165
+ /**
166
+ * Resolve translated field label, falling back to the provided fallback string.
167
+ */
168
+ fieldLabel: (objectName, fieldName, fallback) => resolve(fieldSuffixes(objectName, fieldName), fallback),
169
+ /**
170
+ * Resolve a translated select option label for a given object field.
171
+ * Falls back to the provided fallback (usually the English option label).
172
+ */
173
+ fieldOptionLabel: (objectName, fieldName, optionValue, fallback) => resolve(optionSuffixes(objectName, fieldName, optionValue), fallback),
174
+ /**
175
+ * Translate all options for a given field, returning a new options array
176
+ * with translated labels. Pass the objectName and fieldName to look up
177
+ * translations; the original label is used as fallback.
178
+ */
179
+ translateOptions: (objectName, fieldName, options) => options.map(opt => ({
180
+ ...opt,
181
+ label: resolve(optionSuffixes(objectName, fieldName, opt.value), opt.label),
182
+ })),
183
+ /**
184
+ * Resolve translated app label, falling back to appDef.label.
185
+ * Looks up `{ns}.apps.{appName}.label` from loaded i18next resources.
186
+ */
187
+ appLabel: (appDef) => resolve(`apps.${appDef.name}.label`, appDef.label ?? appDef.name),
188
+ /**
189
+ * Resolve translated app description, falling back to appDef.description.
190
+ * Returns the translated value even when metadata has no description —
191
+ * translation-only descriptions (defined only in i18n bundles) are common
192
+ * in examples where the app metadata is English-only.
193
+ */
194
+ appDescription: (appDef) => {
195
+ const fallback = appDef.description ?? '';
196
+ const resolved = resolve(`apps.${appDef.name}.description`, fallback);
197
+ return resolved || undefined;
198
+ },
199
+ /**
200
+ * Resolve translated label for a navigation group within an app.
201
+ * Convention: `{ns}.apps.{appName}.navigation.{groupId}.label`.
202
+ *
203
+ * Mirrors `objectLabel`/`dashboardLabel` so app metadata can keep
204
+ * English fallbacks while translation packs supply localised
205
+ * sidebar group labels (e.g. "Sales" → "销售") without explicit
206
+ * I18nLabel `{ key, defaultValue }` annotations.
207
+ */
208
+ navGroupLabel: (appName, groupId, fallback) => resolve(`apps.${appName}.navigation.${groupId}.label`, fallback),
209
+ /**
210
+ * Resolve translated dashboard label, falling back to dashboardDef.label.
211
+ * Convention: `{ns}.dashboards.{dashboardName}.label`.
212
+ */
213
+ dashboardLabel: (dashboardDef) => resolve(dashboardSuffixes(dashboardDef.name, 'label'), dashboardDef.label ?? dashboardDef.name),
214
+ /**
215
+ * Resolve translated dashboard description, falling back to
216
+ * dashboardDef.description. Returns undefined when neither metadata nor
217
+ * translation provides one.
218
+ * Convention: `{ns}.dashboards.{dashboardName}.description`.
219
+ */
220
+ dashboardDescription: (dashboardDef) => {
221
+ const fallback = dashboardDef.description ?? '';
222
+ const resolved = resolve(dashboardSuffixes(dashboardDef.name, 'description'), fallback);
223
+ return resolved || undefined;
224
+ },
225
+ /**
226
+ * Resolve translated dashboard header-action label.
227
+ * Convention: `{ns}.dashboards.{dashboardName}.actions.{actionKey}.label`.
228
+ * The actionKey is typically the action's `actionUrl` (e.g.
229
+ * `create_opportunity`) or its English label slugified.
230
+ */
231
+ dashboardActionLabel: (dashboardName, actionKey, fallback) => resolve(dashboardSuffixes(dashboardName, `actions.${actionKey}.label`), fallback),
232
+ /**
233
+ * Resolve translated widget title within a dashboard.
234
+ * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.title`.
235
+ */
236
+ widgetTitle: (dashboardName, widgetId, fallback) => resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.title`), fallback),
237
+ /**
238
+ * Resolve translated widget description within a dashboard.
239
+ * Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.description`.
240
+ * Returns undefined when neither metadata nor translation provides one.
241
+ */
242
+ widgetDescription: (dashboardName, widgetId, fallback) => {
243
+ const fb = fallback ?? '';
244
+ const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
245
+ return resolved || undefined;
246
+ },
247
+ /**
248
+ * Resolve translated page label, falling back to pageDef.label.
249
+ * Convention: `{ns}.pages.{pageName}.label`.
250
+ */
251
+ pageLabel: (pageDef) => resolve(pageSuffixes(pageDef.name, 'label'), pageDef.label ?? pageDef.name),
252
+ /**
253
+ * Resolve translated report label, falling back to reportDef.label.
254
+ * Convention: `{ns}.reports.{reportName}.label`.
255
+ */
256
+ reportLabel: (reportDef) => resolve(reportSuffixes(reportDef.name, 'label'), reportDef.label ?? reportDef.name),
257
+ /**
258
+ * Resolve translated list-view label.
259
+ * Convention (per @objectstack/spec): `{ns}.objects.{objectName}._views.{viewName}.label`.
260
+ */
261
+ viewLabel: (objectName, viewName, fallback) => resolve(objectSuffixes(objectName, `_views.${viewName}.label`), fallback),
262
+ /**
263
+ * Resolve translated list-view description.
264
+ * Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
265
+ */
266
+ viewDescription: (objectName, viewName, fallback) => {
267
+ const fb = fallback ?? '';
268
+ const resolved = resolve(objectSuffixes(objectName, `_views.${viewName}.description`), fb);
269
+ return resolved || undefined;
270
+ },
271
+ /**
272
+ * Resolve translated form-section label.
273
+ * Convention: `{ns}.objects.{objectName}._sections.{sectionName}.label`.
274
+ */
275
+ sectionLabel: (objectName, sectionName, fallback) => resolve(objectSuffixes(objectName, `_sections.${sectionName}.label`), fallback),
276
+ /**
277
+ * Resolve translated action label.
278
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`.
279
+ * Falls back to `{ns}.globalActions.{actionName}.label` when objectName is omitted.
280
+ */
281
+ actionLabel: (objectName, actionName, fallback) => {
282
+ if (objectName) {
283
+ return resolve(objectSuffixes(objectName, `_actions.${actionName}.label`), fallback);
284
+ }
285
+ return resolve(`globalActions.${actionName}.label`, fallback);
286
+ },
287
+ /**
288
+ * Resolve translated action confirmation prompt.
289
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`.
290
+ * Returns undefined when no translation and no fallback exist.
291
+ */
292
+ actionConfirm: (objectName, actionName, fallback) => {
293
+ const fb = fallback ?? '';
294
+ const suffixes = objectName
295
+ ? objectSuffixes(objectName, `_actions.${actionName}.confirmText`)
296
+ : `globalActions.${actionName}.confirmText`;
297
+ const resolved = resolve(suffixes, fb);
298
+ return resolved || undefined;
299
+ },
300
+ /**
301
+ * Resolve translated action success message.
302
+ * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`.
303
+ */
304
+ actionSuccess: (objectName, actionName, fallback) => {
305
+ const fb = fallback ?? '';
306
+ const suffixes = objectName
307
+ ? objectSuffixes(objectName, `_actions.${actionName}.successMessage`)
308
+ : `globalActions.${actionName}.successMessage`;
309
+ const resolved = resolve(suffixes, fb);
310
+ return resolved || undefined;
311
+ },
312
+ };
313
+ }, [t, i18n]);
307
314
  }
315
+ /**
316
+ * Stable identity fallbacks for `useSafeFieldLabel` — referenced from a
317
+ * module-level constant so consumers using the fallback branch don't get a
318
+ * fresh object reference on every render (which would invalidate downstream
319
+ * memoization in heavy components like ListView).
320
+ */
321
+ const SAFE_FIELD_LABEL_FALLBACK = {
322
+ fieldLabel: (_objectName, _fieldName, fallback) => fallback,
323
+ translateOptions: (_objectName, _fieldName, options) => options,
324
+ fieldOptionLabel: (_objectName, _fieldName, _optionValue, fallback) => fallback,
325
+ sectionLabel: (_objectName, _sectionName, fallback) => fallback,
326
+ };
308
327
  /**
309
328
  * Safe wrapper for useObjectLabel that falls back to identity functions
310
329
  * when no I18nProvider is available. Suitable for plugin components that
311
330
  * may be rendered outside an i18n context.
312
331
  */
313
332
  export function useSafeFieldLabel() {
333
+ let resolved = null;
314
334
  try {
315
- const { fieldLabel, translateOptions, fieldOptionLabel, sectionLabel } = useObjectLabel();
316
- return { fieldLabel, translateOptions, fieldOptionLabel, sectionLabel };
335
+ resolved = useObjectLabel();
317
336
  }
318
337
  catch {
319
- return {
320
- fieldLabel: (_objectName, _fieldName, fallback) => fallback,
321
- translateOptions: (_objectName, _fieldName, options) => options,
322
- fieldOptionLabel: (_objectName, _fieldName, _optionValue, fallback) => fallback,
323
- sectionLabel: (_objectName, _sectionName, fallback) => fallback,
324
- };
338
+ resolved = null;
325
339
  }
340
+ // useObjectLabel already returns a stable (memoized) object per language —
341
+ // pass-through avoids creating a fresh wrapper here. The module-level
342
+ // fallback object is reused when no provider is mounted.
343
+ return resolved ?? SAFE_FIELD_LABEL_FALLBACK;
326
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/i18n",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",