diff --git a/.changeset/tags-placeholder-i18n.md b/.changeset/tags-placeholder-i18n.md new file mode 100644 index 000000000..c2ef64f06 --- /dev/null +++ b/.changeset/tags-placeholder-i18n.md @@ -0,0 +1,13 @@ +--- +"@object-ui/fields": patch +"@object-ui/i18n": patch +--- + +`TagsField` no longer ships a hardcoded Chinese input placeholder +(objectui#3342, AGENTS.md Commandment #-1). The placeholder now resolves +through the pinned chain: the author-declared `field.placeholder` wins +(previously ignored by this widget); otherwise the widget's own copy arrives +via `useFieldTranslation()` under the new `fields.tags.placeholder` key, added +at full parity across all locale packs (Chinese lives in the zh pack, not in +code); with no `I18nProvider` mounted the English default from FIELD_DEFAULTS +renders — never a raw key. diff --git a/packages/fields/src/widgets/TagsField.placeholder.no-provider.test.tsx b/packages/fields/src/widgets/TagsField.placeholder.no-provider.test.tsx new file mode 100644 index 000000000..ab061c703 --- /dev/null +++ b/packages/fields/src/widgets/TagsField.placeholder.no-provider.test.tsx @@ -0,0 +1,68 @@ +/** + * 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 tags widget's placeholder with NO `I18nProvider` mounted + * (objectui#3342) — standalone/embedded usage, and every test in the repo + * that renders a field widget bare. + * + * Routing the copy through `useFieldTranslation()` must not turn it into a + * raw i18n key when nothing is configured: `createSafeTranslation` probes the + * instance and falls back to its English defaults map. This is also where the + * Commandment #-1 pin lives: the copy shipped FROM CODE (the no-provider + * default) carries no CJK — Chinese exists only in the zh locale pack and can + * only arrive through a provider. + * + * Why a separate FILE rather than another case in + * `TagsField.placeholder.test.tsx`: mounting `I18nProvider` calls + * `initReactI18next`, which installs that instance as react-i18next's GLOBAL + * default. Once any sibling test has done so there is no "no provider" state + * left to observe in that module graph. Vitest's per-file isolation is what + * makes this file's observation honest (same split as + * `OptionsEmptyState.no-provider.test.tsx`). + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { TagsField } from './TagsField'; + +function renderTags(field: Record) { + return render( + , + ); +} + +const input = () => screen.getByRole('textbox'); + +describe('TagsField placeholder — English fallback survives with no i18n configured', () => { + it('renders the English default, not a raw key', () => { + renderTags({ name: 'labels', type: 'tags' }); + expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…'); + expect(input().getAttribute('placeholder')).not.toContain('fields.tags'); + }); + + it('the code-shipped default carries no CJK (Commandment #-1)', () => { + renderTags({ name: 'labels', type: 'tags' }); + // With no provider mounted, whatever renders here came from CODE + // (FIELD_DEFAULTS) — the commandment pin: no Chinese in the codebase. + // Escaped ranges on purpose: a literal CJK class would itself violate + // the commandment this test enforces (same shape as objectui#3321's pin). + expect(input().getAttribute('placeholder')).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/); + }); + + it('the author-declared field.placeholder still wins without a provider', () => { + renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }); + expect(input()).toHaveAttribute('placeholder', 'Add product labels'); + }); +}); diff --git a/packages/fields/src/widgets/TagsField.placeholder.test.tsx b/packages/fields/src/widgets/TagsField.placeholder.test.tsx new file mode 100644 index 000000000..a0fe29607 --- /dev/null +++ b/packages/fields/src/widgets/TagsField.placeholder.test.tsx @@ -0,0 +1,95 @@ +/** + * 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 tags widget's input placeholder (objectui#3342). + * + * It used to be a hardcoded Chinese literal in code — a double violation: + * Commandment #-1 (English-only codebase) AND a bypass of the i18n channel the + * sibling widgets already use. The pinned resolution chain is: + * + * 1. `field.placeholder` — the field author's declaration always wins; + * 2. `t('fields.tags.placeholder')` — the widget's own copy, from the + * locale packs via `useFieldTranslation()`; + * 3. the English default in FIELD_DEFAULTS when no I18nProvider is mounted + * (that leg lives in `TagsField.placeholder.no-provider.test.tsx` — see + * its header for why it must be a separate file). + * + * Chinese now lives in the zh locale pack only, which is what the zh-locale + * case below observes end-to-end. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { TagsField } from './TagsField'; + +function renderTags( + field: Record, + { value = [] as string[], language }: { value?: string[]; language?: string } = {}, +) { + const element = ( + + ); + return render( + language ? ( + + {element} + + ) : ( + element + ), + ); +} + +const input = () => screen.getByRole('textbox'); + +describe('TagsField placeholder — author declaration beats the widget copy (objectui#3342)', () => { + it('renders the author-declared field.placeholder', () => { + renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'en' }); + expect(input()).toHaveAttribute('placeholder', 'Add product labels'); + }); + + it('the author declaration wins even under a non-English locale', () => { + // The author's copy is theirs verbatim — a locale switch must not + // overwrite an explicit declaration with the widget's own translation. + renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { language: 'zh' }); + expect(input()).toHaveAttribute('placeholder', 'Add product labels'); + expect(input().getAttribute('placeholder')).not.toContain('输入后回车添加'); + }); +}); + +describe('TagsField placeholder — undeclared falls back to the translated key', () => { + it('renders the en pack copy under the en locale', () => { + renderTags({ name: 'labels', type: 'tags' }, { language: 'en' }); + expect(input()).toHaveAttribute('placeholder', 'Type and press Enter to add…'); + }); + + it('renders the zh pack copy under the zh locale — Chinese comes from the pack, not code', () => { + renderTags({ name: 'labels', type: 'tags' }, { language: 'zh' }); + expect(input()).toHaveAttribute('placeholder', '输入后回车添加…'); + // …and never the raw key. + expect(input().getAttribute('placeholder')).not.toContain('fields.tags'); + }); +}); + +describe('TagsField placeholder — shown only while the tag list is empty', () => { + it('is empty once a tag exists (the input is a continuation strip)', () => { + renderTags({ name: 'labels', type: 'tags', placeholder: 'Add product labels' }, { + value: ['alpha'], + language: 'en', + }); + expect(input()).toHaveAttribute('placeholder', ''); + }); +}); diff --git a/packages/fields/src/widgets/TagsField.tsx b/packages/fields/src/widgets/TagsField.tsx index 2f8754137..085c15ff2 100644 --- a/packages/fields/src/widgets/TagsField.tsx +++ b/packages/fields/src/widgets/TagsField.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Badge, Input, EmptyValue, cn } from '@object-ui/components'; import { FieldWidgetComponentProps } from './types'; import { toDomProps } from './toDomProps'; +import { useFieldTranslation } from './useFieldTranslation'; /** * TagsField - free-form list of string tags. Type a value and press Enter (or @@ -11,6 +12,8 @@ import { toDomProps } from './toDomProps'; export function TagsField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps) { const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string]; const [draft, setDraft] = React.useState(''); + // Unconditional hook call (rules-of-hooks): before the readonly early return. + const { t } = useFieldTranslation(); if (readonly) { if (tags.length === 0) return ; @@ -64,7 +67,15 @@ export function TagsField({ value, onChange, field, readonly, className, error, onKeyDown={onKeyDown} onBlur={() => addTag(draft)} disabled={props.disabled} - placeholder={tags.length === 0 ? '输入后回车添加…' : ''} + // Placeholder chain (objectui#3342): the author-declared + // `field.placeholder` wins; otherwise the widget's own copy arrives + // via `useFieldTranslation()` — `fields.tags.placeholder` in the + // locale packs, English default from FIELD_DEFAULTS when no + // I18nProvider is mounted. This used to be a hardcoded Chinese + // literal (Commandment #-1); Chinese now lives in the zh pack only. + // Shown only while the list is empty — once a tag exists the input is + // a small continuation strip and the hint would be noise. + placeholder={tags.length === 0 ? field?.placeholder || t('fields.tags.placeholder') : ''} className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]" // AFTER the spread so this widget's own computation wins (the #3222 // discipline): `error` is the published validation slot, and diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 1edefa47d..b359a4db5 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -75,6 +75,9 @@ const FIELD_DEFAULTS: Record = { 'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON', 'fields.filterCondition.editAsJson': 'Edit as JSON', 'fields.filterCondition.useVisualBuilder': 'Use visual builder', + // objectui#3342 — the tags widget's input hint. Used only when the field + // author declared no `placeholder` of their own (author declaration wins). + 'fields.tags.placeholder': 'Type and press Enter to add…', // objectui#2600 B5 — capability picker scope group headers. 'capability.group.platform': 'Platform', 'capability.group.org': 'Organization', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index bd2676b1f..039b54328 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -203,6 +203,9 @@ const ar = { editAsJson: "التحرير بصيغة JSON", useVisualBuilder: "استخدام المُنشئ المرئي", }, + tags: { + placeholder: "اكتب واضغط Enter للإضافة…", + }, }, table: { rowsPerPage: "صفوف في الصفحة", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a5ad98059..293d05249 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -203,6 +203,9 @@ const de = { editAsJson: "Als JSON bearbeiten", useVisualBuilder: "Visuellen Builder verwenden", }, + tags: { + placeholder: "Tippen und mit der Eingabetaste hinzufügen…", + }, }, table: { rowsPerPage: "Zeilen pro Seite", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 47c8ca9db..6d9955d60 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -213,6 +213,11 @@ const en = { editAsJson: 'Edit as JSON', useVisualBuilder: 'Use visual builder', }, + // objectui#3342 — the tags widget's input hint, shown while the tag list + // is empty. The author-declared `field.placeholder` always wins over this. + tags: { + placeholder: 'Type and press Enter to add…', + }, }, table: { rowsPerPage: 'Rows per page', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 04ad5c6cb..ba7fae6a8 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -203,6 +203,9 @@ const es = { editAsJson: "Editar como JSON", useVisualBuilder: "Usar el constructor visual", }, + tags: { + placeholder: "Escriba y pulse Intro para añadir…", + }, }, table: { rowsPerPage: "Filas por página", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f6ded0857..fe0d0ef30 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -203,6 +203,9 @@ const fr = { editAsJson: "Modifier en JSON", useVisualBuilder: "Utiliser le constructeur visuel", }, + tags: { + placeholder: "Saisissez puis appuyez sur Entrée pour ajouter…", + }, }, table: { rowsPerPage: "Lignes par page", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index acd0bfbce..f806884a4 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -203,6 +203,9 @@ const ja = { editAsJson: "JSON で編集", useVisualBuilder: "ビジュアルビルダーを使用", }, + tags: { + placeholder: "入力してEnterキーで追加…", + }, }, table: { rowsPerPage: "1ページの行数", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index b436817ba..2f9830d04 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -203,6 +203,9 @@ const ko = { editAsJson: "JSON으로 편집", useVisualBuilder: "비주얼 빌더 사용", }, + tags: { + placeholder: "입력 후 Enter 키로 추가…", + }, }, table: { rowsPerPage: "페이지당 행 수", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 8b6cac503..549584703 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -203,6 +203,9 @@ const pt = { editAsJson: "Editar como JSON", useVisualBuilder: "Usar o construtor visual", }, + tags: { + placeholder: "Digite e pressione Enter para adicionar…", + }, }, table: { rowsPerPage: "Linhas por página", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 49c913dec..f44b43af3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -203,6 +203,9 @@ const ru = { editAsJson: "Редактировать как JSON", useVisualBuilder: "Использовать визуальный конструктор", }, + tags: { + placeholder: "Введите и нажмите Enter, чтобы добавить…", + }, }, table: { rowsPerPage: "Строк на странице", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index e944f4105..7f8d0ea5e 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -208,6 +208,9 @@ const zh = { editAsJson: '以 JSON 编辑', useVisualBuilder: '使用可视化构建器', }, + tags: { + placeholder: '输入后回车添加…', + }, }, table: { rowsPerPage: '每页行数',