From 3e707d4fcae8aa0f62bb64f99c2a16768b43b460 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:17:55 +0000 Subject: [PATCH 1/2] fix(components,i18n): route form.tsx's remaining user-visible English through i18n (#3272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fullscreen long-text dialog (title, sr-only description, footer buttons, trigger accessible name) now reads `form.fullscreen.*`; the action bar's `submitLabel`/`cancelLabel` lose their English literal defaults and fall back at render through `common.submit`/`common.cancel`, so an authored label still wins verbatim while an unset one follows the session language. The built-in select branch's second `|| 'Select an option'` fallback is dropped — the call site already supplies `t('common.selectOption')`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../components/src/renderers/form/form.tsx | 67 ++++++++++++++++--- packages/i18n/src/locales/ar.ts | 7 ++ packages/i18n/src/locales/de.ts | 7 ++ packages/i18n/src/locales/en.ts | 7 ++ packages/i18n/src/locales/es.ts | 7 ++ packages/i18n/src/locales/fr.ts | 7 ++ packages/i18n/src/locales/ja.ts | 7 ++ packages/i18n/src/locales/ko.ts | 7 ++ packages/i18n/src/locales/pt.ts | 7 ++ packages/i18n/src/locales/ru.ts | 7 ++ packages/i18n/src/locales/zh.ts | 7 ++ 11 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 59c1de8d33..922816e6ba 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -110,6 +110,28 @@ const panePercent = (size: number | undefined): string | undefined => const useSafeFormTranslation = createSafeTranslation( { 'common.selectOption': 'Select an option', + // objectui#3272 — the action bar's button copy when the schema authored no + // `submitLabel` / `cancelLabel`. Deliberately the SHARED `common.*` keys + // rather than new `form.*` twins: "Submit" and "Cancel" are the same words + // the rest of the console already ships in ten packs, and a second copy of + // a one-word string is exactly the drift #3231/#3263 had to undo. The + // defaults here are byte-identical to the English literals they replaced, + // so a form with no I18nProvider renders what it always did. + 'common.submit': 'Submit', + 'common.cancel': 'Cancel', + // objectui#3272 — the fullscreen long-text editor (`mobile_fullscreen`). + // A whole dialog, not a word: title, its sr-only description, the footer's + // confirm button, and the trigger's accessible name. `Cancel` in the footer + // reuses `common.cancel` above for the same reason. + 'form.fullscreen.title': 'Edit text', + 'form.fullscreen.description': 'Edit the full text value, then save or cancel your changes.', + 'form.fullscreen.done': 'Done', + // Two keys rather than one with a defaulted `{{label}}`: an interpolation + // that falls back to an untranslated English noun ("text") would leak that + // noun into every other language's sentence. The unlabeled case is its own + // sentence so each pack can word it naturally. + 'form.fullscreen.toggle': 'Edit {{label}} fullscreen', + 'form.fullscreen.toggleUnlabeled': 'Edit text fullscreen', // objectui#3231 — the dependency-gate sentence (#2284). Shared with the // option widgets' own fallback so both sides render one wording. 'fields.options.selectFirst': 'Select {{fields}} first', @@ -342,6 +364,10 @@ function FullscreenTextarea({ label?: string; [key: string]: any; }) { + // A real component (rendered as ``), so the hook runs + // unconditionally — unlike `renderFieldComponent`, the plain helper below + // that had to grow `BuiltinSelectEmptyState` to own its own hook (#3263). + const { t } = useSafeFormTranslation(); const [open, setOpen] = React.useState(false); const [draft, setDraft] = React.useState(value ?? ''); const safeOnChange = (v: string) => onChange && onChange(v); @@ -360,7 +386,11 @@ function FullscreenTextarea({ type="button" onClick={openDialog} className="absolute top-1.5 right-1.5 inline-flex items-center justify-center size-7 rounded-md bg-background/80 text-muted-foreground hover:text-foreground hover:bg-background border shadow-sm" - aria-label={`Edit ${label ?? 'text'} fullscreen`} + aria-label={ + label + ? t('form.fullscreen.toggle', { label }) + : t('form.fullscreen.toggleUnlabeled') + } data-testid="form-textarea-fullscreen-toggle" > @@ -371,9 +401,9 @@ function FullscreenTextarea({ data-testid="form-textarea-fullscreen-dialog" > - {label ?? 'Edit text'} + {label ?? t('form.fullscreen.title')} - Edit the full text value, then save or cancel your changes. + {t('form.fullscreen.description')}
@@ -388,10 +418,10 @@ function FullscreenTextarea({
@@ -407,8 +437,16 @@ ComponentRegistry.register('form', const { defaultValues: authoredDefaultValues = {}, fields: rawFields = [], - submitLabel = 'Submit', - cancelLabel = 'Cancel', + // No English default here — objectui#3272. A literal default made the + // absence of an authored label indistinguishable from an author who + // typed "Submit", and froze every un-labelled form's buttons to English + // in a zh/ja/ar session. The default is now the ABSENCE of a value, and + // the fallback happens at render through `t()` (see the action bar + // below), so `submitLabel?: string` staying optional in the spec means + // exactly what it says: unset = "whatever this session's language calls + // it". An authored value still wins verbatim, including `''`. + submitLabel, + cancelLabel, showCancel = false, showSubmit = true, layout = 'vertical', @@ -1719,7 +1757,10 @@ ComponentRegistry.register('form', disabled={isSubmitting || disabled} className="w-full sm:w-auto" > - {cancelLabel} + {/* `??`, not `||`: an authored `cancelLabel: ''` is an + explicit choice (an icon-only / deliberately blank + button), not "unset". Only null/undefined falls back. */} + {cancelLabel ?? t('common.cancel')} )} {showSubmit && ( @@ -1729,7 +1770,7 @@ ComponentRegistry.register('form', className="w-full sm:w-auto" > {isSubmitting && } - {submitLabel} + {submitLabel ?? t('common.submit')} )} @@ -2044,7 +2085,13 @@ function renderFieldComponent(type: string, props: RenderFieldProps) { {...selectProps} > - + {/* No `|| 'Select an option'` — objectui#3272. The single call site + already supplies `t('common.selectOption')` for `select`, so the + literal was all but unreachable; the ONE stack that did reach it + was an authored `placeholder: ''` (the call site's `??` keeps an + empty string), where a second fallback overrode the author's + explicit blank with an untranslated English word. */} + {options.map((opt: SelectOption) => ( diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 4025573946..55618168a6 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -135,6 +135,13 @@ const ar = { createSuccess: "تم إنشاء {{object}}", updateSuccess: "تم تحديث {{object}}", deleteSuccess: "تم حذف {{object}}", + fullscreen: { + title: "تحرير النص", + description: "حرّر قيمة النص الكاملة، ثم احفظ التغييرات أو ألغِها.", + done: "تم", + toggle: "تحرير {{label}} بملء الشاشة", + toggleUnlabeled: "تحرير النص بملء الشاشة", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 494f0384b6..2f832d4d8a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -135,6 +135,13 @@ const de = { createSuccess: "{{object}} erfolgreich erstellt", updateSuccess: "{{object}} erfolgreich aktualisiert", deleteSuccess: "{{object}} erfolgreich gelöscht", + fullscreen: { + title: "Text bearbeiten", + description: "Bearbeiten Sie den vollständigen Textwert und speichern oder verwerfen Sie dann Ihre Änderungen.", + done: "Fertig", + toggle: "{{label}} im Vollbild bearbeiten", + toggleUnlabeled: "Text im Vollbild bearbeiten", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e09326098f..57f14fd95e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -146,6 +146,13 @@ const en = { createSuccess: '{{object}} created successfully', updateSuccess: '{{object}} updated successfully', deleteSuccess: '{{object}} deleted successfully', + fullscreen: { + title: 'Edit text', + description: 'Edit the full text value, then save or cancel your changes.', + done: 'Done', + toggle: 'Edit {{label}} fullscreen', + toggleUnlabeled: 'Edit text fullscreen', + }, }, fields: { relativeDate: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 99510c3ca0..e0bc526a51 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -140,6 +140,13 @@ const es = { createSuccess: "{{object}} creado", updateSuccess: "{{object}} actualizado", deleteSuccess: "{{object}} eliminado", + fullscreen: { + title: "Editar texto", + description: "Edita el valor de texto completo y luego guarda o cancela los cambios.", + done: "Listo", + toggle: "Editar {{label}} en pantalla completa", + toggleUnlabeled: "Editar texto en pantalla completa", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 78f382475d..5c89ae2379 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -135,6 +135,13 @@ const fr = { createSuccess: "{{object}} créé", updateSuccess: "{{object}} mis à jour", deleteSuccess: "{{object}} supprimé", + fullscreen: { + title: "Modifier le texte", + description: "Modifiez la valeur texte complète, puis enregistrez ou annulez vos modifications.", + done: "Terminé", + toggle: "Modifier {{label}} en plein écran", + toggleUnlabeled: "Modifier le texte en plein écran", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index e8098e34d6..897900471d 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -135,6 +135,13 @@ const ja = { createSuccess: "{{object}}が作成されました", updateSuccess: "{{object}}が更新されました", deleteSuccess: "{{object}}が削除されました", + fullscreen: { + title: "テキストを編集", + description: "テキスト全体を編集してから、変更を保存またはキャンセルしてください。", + done: "完了", + toggle: "{{label}}を全画面で編集", + toggleUnlabeled: "テキストを全画面で編集", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index de854c7511..3eb00bc07f 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -135,6 +135,13 @@ const ko = { createSuccess: "{{object}} 생성됨", updateSuccess: "{{object}} 업데이트됨", deleteSuccess: "{{object}} 삭제됨", + fullscreen: { + title: "텍스트 편집", + description: "전체 텍스트 값을 편집한 다음 변경 사항을 저장하거나 취소하세요.", + done: "완료", + toggle: "{{label}} 전체 화면으로 편집", + toggleUnlabeled: "텍스트 전체 화면으로 편집", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index d7a395988a..4160e5d29f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -135,6 +135,13 @@ const pt = { createSuccess: "{{object}} criado", updateSuccess: "{{object}} atualizado", deleteSuccess: "{{object}} excluído", + fullscreen: { + title: "Editar texto", + description: "Edite o valor de texto completo e depois salve ou cancele as alterações.", + done: "Concluído", + toggle: "Editar {{label}} em tela cheia", + toggleUnlabeled: "Editar texto em tela cheia", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index aa59f70a7d..30f13706bf 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -135,6 +135,13 @@ const ru = { createSuccess: "{{object}} создан", updateSuccess: "{{object}} обновлён", deleteSuccess: "{{object}} удалён", + fullscreen: { + title: "Изменить текст", + description: "Измените полное текстовое значение, затем сохраните или отмените изменения.", + done: "Готово", + toggle: "Редактировать {{label}} в полноэкранном режиме", + toggleUnlabeled: "Редактировать текст в полноэкранном режиме", + }, }, fields: { image: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 22df664918..f2d8eac73f 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -140,6 +140,13 @@ const zh = { createSuccess: '{{object}}创建成功', updateSuccess: '{{object}}更新成功', deleteSuccess: '{{object}}删除成功', + fullscreen: { + title: '编辑文本', + description: '编辑完整的文本内容,然后保存或取消更改。', + done: '完成', + toggle: '全屏编辑{{label}}', + toggleUnlabeled: '全屏编辑文本', + }, }, fields: { relativeDate: { From dbe4d45e4db15bae830a06a304e62a563e187590 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:29:01 +0000 Subject: [PATCH 2/2] test(components): pin form.tsx's i18n fallbacks + authored-label precedence (#3272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three suites: the fullscreen dialog in en/zh/ja (positive per locale plus the English literals asserted absent, and a commit-the-draft case so the translated Done button is still the one wired to the handler); the action bar's submit/cancel fallback with the authored-label-wins-verbatim nail in both directions and the `submitLabel: ''` case that pins `??` over `||`; and the built-in select placeholder, whose authored-empty case is the one stack the deleted `|| 'Select an option'` literal actually reached. Also adds the changeset — the item-2 default-value semantics change is spelled out there for release notes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../form-remaining-hardcoded-english.md | 10 ++ .../form-action-labels-i18n.test.tsx | 107 +++++++++++++++ .../form-builtin-select-placeholder.test.tsx | 88 ++++++++++++ .../form-fullscreen-textarea-i18n.test.tsx | 125 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 23 ++-- packages/i18n/src/locales/ar.ts | 2 +- packages/i18n/src/locales/de.ts | 2 +- packages/i18n/src/locales/en.ts | 2 +- packages/i18n/src/locales/es.ts | 2 +- packages/i18n/src/locales/fr.ts | 2 +- packages/i18n/src/locales/ja.ts | 2 +- packages/i18n/src/locales/ko.ts | 2 +- packages/i18n/src/locales/pt.ts | 2 +- packages/i18n/src/locales/ru.ts | 2 +- packages/i18n/src/locales/zh.ts | 2 +- 15 files changed, 353 insertions(+), 20 deletions(-) create mode 100644 .changeset/form-remaining-hardcoded-english.md create mode 100644 packages/components/src/renderers/form/__tests__/form-action-labels-i18n.test.tsx create mode 100644 packages/components/src/renderers/form/__tests__/form-builtin-select-placeholder.test.tsx create mode 100644 packages/components/src/renderers/form/__tests__/form-fullscreen-textarea-i18n.test.tsx diff --git a/.changeset/form-remaining-hardcoded-english.md b/.changeset/form-remaining-hardcoded-english.md new file mode 100644 index 0000000000..d41a5179ce --- /dev/null +++ b/.changeset/form-remaining-hardcoded-english.md @@ -0,0 +1,10 @@ +--- +'@object-ui/components': patch +'@object-ui/i18n': patch +--- + +The form renderer's last user-visible English literals now go through i18n (#3272). The fullscreen long-text editor (`mobile_fullscreen`) was an entire untranslated dialog — title, screen-reader description, `Cancel` / `Done` footer buttons, and the expand trigger's accessible name — rendering English inside an otherwise translated zh/ja/ar form; it now reads the new `form.fullscreen.*` keys, shipped in all ten locale packs. + +**Behaviour change worth reading if you author forms:** `submitLabel` and `cancelLabel` no longer default to the literals `'Submit'` and `'Cancel'` in the renderer. They default to *unset*, and the action bar falls back at render time to `common.submit` / `common.cancel`, so a form that declares no button copy now follows the session language instead of being silently frozen to English. A label you DO declare still wins verbatim in every locale — including an English one under a zh session, and including an explicit empty string (the fallback uses `??`, so `submitLabel: ''` renders a blank button rather than being overwritten). The only forms whose rendered text changes are those that never declared the labels and are viewed in a non-English session — which is the bug. `FormSchema.submitLabel` / `cancelLabel` stay optional strings; no spec or type change. + +Also removed the built-in `select` branch's second `|| 'Select an option'` fallback. The single call site already supplies `t('common.selectOption')`, so the literal was reachable only through an authored `placeholder: ''` — where it replaced the author's deliberate blank with an untranslated English word. diff --git a/packages/components/src/renderers/form/__tests__/form-action-labels-i18n.test.tsx b/packages/components/src/renderers/form/__tests__/form-action-labels-i18n.test.tsx new file mode 100644 index 0000000000..da3bafa70b --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-action-labels-i18n.test.tsx @@ -0,0 +1,107 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Un-authored submit/cancel button copy follows the session locale — #3272. + * + * `submitLabel` / `cancelLabel` used to carry English DEFAULTS in the + * destructuring (`submitLabel = 'Submit'`). That made two different things + * indistinguishable — "the author said nothing" and "the author typed + * Submit" — and locked every un-labelled form's action bar to English in a + * zh/ja/ar session, which no amount of translating the rest of the form could + * undo. The default is now the ABSENCE of a value; the fallback happens at + * render through `common.submit` / `common.cancel`. + * + * The direction that matters most here is the SECOND one: an authored label + * must still win VERBATIM. A render-time fallback is only safe if it cannot + * reach a form whose author did declare the copy — otherwise the fix would + * have traded one silent override for another, translating text the author + * deliberately wrote. Both directions are pinned below, and the `''` case + * pins `??` (not `||`), which is the whole difference between "unset" and + * "explicitly blank". + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +// Module scope, not `beforeAll` (objectui#3010/#3021). +import '../../../renderers'; + +const fields = [{ name: 'name', label: 'Name', type: 'input' }]; + +function renderFormIn(language: string, schemaExtra: Record = {}) { + const Form = ComponentRegistry.get('form')!; + return render( + +
+ , + ); +} + +describe('form renderer — action-bar labels fall back through i18n (objectui#3272)', () => { + it('renders the English words under an en provider', () => { + // Byte-identical to the literal defaults this replaced. + renderFormIn('en'); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('renders Chinese under a zh provider', () => { + renderFormIn('zh'); + + expect(screen.getByRole('button', { name: '提交' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument(); + // The literals, asserted absent. + expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + }); + + it('renders Japanese under a ja provider', () => { + renderFormIn('ja'); + + expect(screen.getByRole('button', { name: '送信' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'キャンセル' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); + }); + + it('an authored label wins VERBATIM over the locale fallback', () => { + // The nail this whole change hangs on: the fallback may only fill an + // absence. An authored string is the author's copy — including an English + // one under a zh session, and including one that happens to be a word the + // locale pack also knows. + renderFormIn('zh', { submitLabel: 'Create account', cancelLabel: 'Nevermind' }); + + expect(screen.getByRole('button', { name: 'Create account' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Nevermind' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '提交' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument(); + }); + + it('an authored label wins verbatim in the OTHER direction too', () => { + // Authored Chinese under an `en` session — the fallback is not a + // "translate the button" step, it is a "fill the blank" step. + renderFormIn('en', { submitLabel: '立即提交' }); + + expect(screen.getByRole('button', { name: '立即提交' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); + }); + + it('an authored empty label stays empty — `??`, not `||`', () => { + // `submitLabel: ''` is a declaration ("render no text"), not an absence. + // A `||` fallback would silently overwrite it with the locale word, which + // is the same class of override the English default was. + const { container } = renderFormIn('zh', { submitLabel: '', cancelLabel: '' }); + + const submit = container.querySelector('button[type="submit"]'); + expect(submit).not.toBeNull(); + expect(submit!.textContent).toBe(''); + expect(screen.queryByRole('button', { name: '提交' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/renderers/form/__tests__/form-builtin-select-placeholder.test.tsx b/packages/components/src/renderers/form/__tests__/form-builtin-select-placeholder.test.tsx new file mode 100644 index 0000000000..7762ce6086 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-builtin-select-placeholder.test.tsx @@ -0,0 +1,88 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The built-in `select` branch has ONE placeholder source — objectui#3272. + * + * The branch used to render `placeholder || 'Select an option'` behind a call + * site that already supplies `t('common.selectOption')`, so the literal read + * as unreachable dead code. It was not quite: the call site defaults with `??`, + * which PRESERVES an authored `placeholder: ''`, and an empty string is falsy + * — so the one stack that reached the literal was an author who deliberately + * asked for a blank placeholder and got an untranslated English word instead. + * + * That is also the honest reverse-verification direction for this deletion: + * restoring `|| 'Select an option'` turns the `placeholder: ''` case below red + * and leaves the other two green, because those two never reached the literal + * in the first place. They are here as the surviving pin — if a later refactor + * drops the `t()` at the call site, there is no longer a second fallback to + * mask it, and the zh case fails instead of quietly rendering English. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +// Module scope, not `beforeAll` (objectui#3010/#3021). +import '../../../renderers'; + +const options = [ + { label: 'Zhejiang', value: 'zj' }, + { label: 'California', value: 'ca' }, +]; + +function renderSelectIn(language: string, fieldExtra: Record = {}) { + const Form = ComponentRegistry.get('form')!; + return render( + + + , + ); +} + +describe('form renderer — built-in select placeholder (objectui#3272)', () => { + it('uses the locale word when the author declared no placeholder (zh)', () => { + renderSelectIn('zh'); + + expect(screen.getByText('请选择')).toBeInTheDocument(); + expect(screen.queryByText('Select an option')).not.toBeInTheDocument(); + }); + + it('uses the English word under an en provider', () => { + renderSelectIn('en'); + + expect(screen.getByText('Select an option')).toBeInTheDocument(); + }); + + it('honours an authored placeholder verbatim, in any locale', () => { + renderSelectIn('zh', { placeholder: 'Pick a province' }); + + expect(screen.getByText('Pick a province')).toBeInTheDocument(); + expect(screen.queryByText('请选择')).not.toBeInTheDocument(); + expect(screen.queryByText('Select an option')).not.toBeInTheDocument(); + }); + + it('honours an authored EMPTY placeholder instead of substituting English', () => { + // The stack the deleted literal actually reached: `?? ` at the call site + // keeps `''`, then `||` in the branch overrode it. Asserted in BOTH + // locales because the defect was locale-independent — an `en` author who + // asked for a blank placeholder was overridden just the same. + renderSelectIn('zh', { placeholder: '' }); + expect(screen.queryByText('Select an option')).not.toBeInTheDocument(); + expect(screen.queryByText('请选择')).not.toBeInTheDocument(); + + renderSelectIn('en', { placeholder: '' }); + expect(screen.queryByText('Select an option')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/renderers/form/__tests__/form-fullscreen-textarea-i18n.test.tsx b/packages/components/src/renderers/form/__tests__/form-fullscreen-textarea-i18n.test.tsx new file mode 100644 index 0000000000..6c80969fc9 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-fullscreen-textarea-i18n.test.tsx @@ -0,0 +1,125 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The fullscreen long-text editor speaks the session locale — objectui#3272. + * + * `FullscreenTextarea` is a whole dialog (title, sr-only description, footer + * buttons) plus the trigger's accessible name, and NONE of it went through + * `t()`: a zh/ja/ar session opened a dialog that read "Edit text" / "Cancel" / + * "Done" while every other sentence in the same form was translated. + * + * The fixture spells the flag `mobile_fullscreen` — the one carrier + * `ObjectForm` actually produces (#3245/#3300). The built-in branch also reads + * an aliased `fullscreen`, but that alias has no producer and is #3303's to + * remove; pinning the canonical spelling keeps this suite green either way. + * + * Every locale case asserts the English literal is GONE as well as that the + * translation is present: a re-inlined literal alongside a translated sibling + * would still satisfy a positive-only assertion. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +// Registered at module scope, NOT in a `beforeAll` — there the cold transform +// is billed to `hookTimeout` (objectui#3010/#3021). +import '../../../renderers'; + +const fields = [ + { name: 'notes', label: 'Notes', type: 'textarea', mobile_fullscreen: true }, +]; + +function renderFormIn(language: string) { + const Form = ComponentRegistry.get('form')!; + return render( + + + , + ); +} + +/** Open the dialog and hand back the trigger, so its name can be asserted too. */ +function openFullscreen() { + const toggle = screen.getByTestId('form-textarea-fullscreen-toggle'); + fireEvent.click(toggle); + return toggle; +} + +describe('form renderer — fullscreen textarea dialog is translated (objectui#3272)', () => { + it('renders the English copy under an en provider', () => { + renderFormIn('en'); + const toggle = openFullscreen(); + + // Byte-identical to the literals this replaced, so `en` is a no-op change. + expect(toggle).toHaveAttribute('aria-label', 'Edit text fullscreen'); + expect(screen.getByText('Edit text')).toBeInTheDocument(); + expect( + screen.getByText('Edit the full text value, then save or cancel your changes.'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); + }); + + it('renders the whole dialog in Chinese under a zh provider', () => { + renderFormIn('zh'); + const toggle = openFullscreen(); + + expect(toggle).toHaveAttribute('aria-label', '全屏编辑文本'); + expect(screen.getByText('编辑文本')).toBeInTheDocument(); + expect(screen.getByText('编辑完整的文本内容,然后保存或取消更改。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '完成' })).toBeInTheDocument(); + + // The four literals, asserted absent. `Edit text` is matched loosely + // because it was BOTH the dialog title and part of the trigger's name. + expect(screen.queryByText(/edit text/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/save or cancel your changes/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Done' })).not.toBeInTheDocument(); + expect(toggle.getAttribute('aria-label')).not.toMatch(/fullscreen/i); + }); + + it('renders the whole dialog in Japanese under a ja provider', () => { + // A second non-en pack because the trigger's name is INTERPOLATED + // (`{{label}}` + a translated generic noun): ja puts the noun first + // ("テキストを全画面で編集"), en last. A pack that dropped the + // interpolation would still pass a zh-only assertion by accident. + renderFormIn('ja'); + const toggle = openFullscreen(); + + expect(toggle).toHaveAttribute('aria-label', 'テキストを全画面で編集'); + expect(screen.getByText('テキストを編集')).toBeInTheDocument(); + expect( + screen.getByText('テキスト全体を編集してから、変更を保存またはキャンセルしてください。'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'キャンセル' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '完了' })).toBeInTheDocument(); + + expect(screen.queryByText(/edit text/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Done' })).not.toBeInTheDocument(); + }); + + it('still commits the draft — translating the footer did not unwire it', () => { + // The `Done` button lost its literal child; this pins that the click + // handler still rides on the translated button rather than on some other + // node that happened to carry the old text. + renderFormIn('zh'); + openFullscreen(); + + fireEvent.change(screen.getByTestId('form-textarea-fullscreen-input'), { + target: { value: 'hello' }, + }); + fireEvent.click(screen.getByRole('button', { name: '完成' })); + + expect(screen.queryByTestId('form-textarea-fullscreen-dialog')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Notes')).toHaveValue('hello'); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 922816e6ba..eb8cf43880 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -126,12 +126,17 @@ const useSafeFormTranslation = createSafeTranslation( 'form.fullscreen.title': 'Edit text', 'form.fullscreen.description': 'Edit the full text value, then save or cancel your changes.', 'form.fullscreen.done': 'Done', - // Two keys rather than one with a defaulted `{{label}}`: an interpolation - // that falls back to an untranslated English noun ("text") would leak that - // noun into every other language's sentence. The unlabeled case is its own - // sentence so each pack can word it naturally. + // The trigger's accessible name keeps the literal's shape — + // `Edit ${label ?? 'text'} fullscreen` — as ONE sentence with a + // TRANSLATED generic noun standing in for a missing label. The obvious + // alternative (a second, label-less sentence key) would ship dead in all + // ten packs: `label` is destructured off the field config before + // `...fieldProps`, so the only caller never forwards it and the labelled + // limb is unreachable today (filed separately). Interpolating a + // translated noun keeps BOTH keys on the live path while leaving the + // limb's fate to that issue. 'form.fullscreen.toggle': 'Edit {{label}} fullscreen', - 'form.fullscreen.toggleUnlabeled': 'Edit text fullscreen', + 'form.fullscreen.textFallback': 'text', // objectui#3231 — the dependency-gate sentence (#2284). Shared with the // option widgets' own fallback so both sides render one wording. 'fields.options.selectFirst': 'Select {{fields}} first', @@ -386,11 +391,9 @@ function FullscreenTextarea({ type="button" onClick={openDialog} className="absolute top-1.5 right-1.5 inline-flex items-center justify-center size-7 rounded-md bg-background/80 text-muted-foreground hover:text-foreground hover:bg-background border shadow-sm" - aria-label={ - label - ? t('form.fullscreen.toggle', { label }) - : t('form.fullscreen.toggleUnlabeled') - } + aria-label={t('form.fullscreen.toggle', { + label: label ?? t('form.fullscreen.textFallback'), + })} data-testid="form-textarea-fullscreen-toggle" > diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 55618168a6..76f2ea2965 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -140,7 +140,7 @@ const ar = { description: "حرّر قيمة النص الكاملة، ثم احفظ التغييرات أو ألغِها.", done: "تم", toggle: "تحرير {{label}} بملء الشاشة", - toggleUnlabeled: "تحرير النص بملء الشاشة", + textFallback: "النص", }, }, fields: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 2f832d4d8a..fb43b8a6e6 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -140,7 +140,7 @@ const de = { description: "Bearbeiten Sie den vollständigen Textwert und speichern oder verwerfen Sie dann Ihre Änderungen.", done: "Fertig", toggle: "{{label}} im Vollbild bearbeiten", - toggleUnlabeled: "Text im Vollbild bearbeiten", + textFallback: "Text", }, }, fields: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 57f14fd95e..3fe872f9e4 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -151,7 +151,7 @@ const en = { description: 'Edit the full text value, then save or cancel your changes.', done: 'Done', toggle: 'Edit {{label}} fullscreen', - toggleUnlabeled: 'Edit text fullscreen', + textFallback: 'text', }, }, fields: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e0bc526a51..8ce1e96e20 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -145,7 +145,7 @@ const es = { description: "Edita el valor de texto completo y luego guarda o cancela los cambios.", done: "Listo", toggle: "Editar {{label}} en pantalla completa", - toggleUnlabeled: "Editar texto en pantalla completa", + textFallback: "texto", }, }, fields: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5c89ae2379..2961c8f1c2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -140,7 +140,7 @@ const fr = { description: "Modifiez la valeur texte complète, puis enregistrez ou annulez vos modifications.", done: "Terminé", toggle: "Modifier {{label}} en plein écran", - toggleUnlabeled: "Modifier le texte en plein écran", + textFallback: "le texte", }, }, fields: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 897900471d..d068cb0d92 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -140,7 +140,7 @@ const ja = { description: "テキスト全体を編集してから、変更を保存またはキャンセルしてください。", done: "完了", toggle: "{{label}}を全画面で編集", - toggleUnlabeled: "テキストを全画面で編集", + textFallback: "テキスト", }, }, fields: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 3eb00bc07f..f134ac0ede 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -140,7 +140,7 @@ const ko = { description: "전체 텍스트 값을 편집한 다음 변경 사항을 저장하거나 취소하세요.", done: "완료", toggle: "{{label}} 전체 화면으로 편집", - toggleUnlabeled: "텍스트 전체 화면으로 편집", + textFallback: "텍스트", }, }, fields: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 4160e5d29f..8a74482841 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -140,7 +140,7 @@ const pt = { description: "Edite o valor de texto completo e depois salve ou cancele as alterações.", done: "Concluído", toggle: "Editar {{label}} em tela cheia", - toggleUnlabeled: "Editar texto em tela cheia", + textFallback: "texto", }, }, fields: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 30f13706bf..e5c216bee1 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -140,7 +140,7 @@ const ru = { description: "Измените полное текстовое значение, затем сохраните или отмените изменения.", done: "Готово", toggle: "Редактировать {{label}} в полноэкранном режиме", - toggleUnlabeled: "Редактировать текст в полноэкранном режиме", + textFallback: "текст", }, }, fields: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index f2d8eac73f..f04dd99d61 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -145,7 +145,7 @@ const zh = { description: '编辑完整的文本内容,然后保存或取消更改。', done: '完成', toggle: '全屏编辑{{label}}', - toggleUnlabeled: '全屏编辑文本', + textFallback: '文本', }, }, fields: {