From 6b8980039cc63c20919ba951740886fc3823cfff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:36:06 +0000 Subject: [PATCH] =?UTF-8?q?fix(components,i18n):=20=E6=8A=8A=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=B5=AE=E5=B1=82=E4=B8=8E=E9=A1=B5=E7=AD=BE=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E5=BE=BD=E6=A0=87=E7=9A=84=E5=89=A9=E4=BD=99=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E8=8B=B1=E6=96=87=E6=8E=A5=E5=85=A5=20i18n?= =?UTF-8?q?=20(objectstack#5506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5430 之后仍有四处控制台 chrome 是硬编码英文字面量。和 #5430 不同的是, 这批里有一处是**可见文案**,还有一处是组件**默认值** —— 只是恰好被控制台 自己覆盖掉了,别的宿主拿到的就是那句英文。 - page:tabs 计数徽标的 aria-label 原本是模板字符串 `${formatTabCount(count)} items`。 徽标只渲染数字,这个 label 对屏幕阅读器而言就是徽标本身;而且英文复数是 拼接出来的,根本没有单数分支 —— 只有一条关联记录时会念成 "1 items"。 现在走 common.itemCount / common.itemCountOne。 - NavigationOverlay 的拖拽改宽手柄(role="separator",完全没有可见标签) → common.resizeDrawer。 - NavigationOverlay 的 expandLabel **默认值**。宿主可以覆盖(控制台的 ObjectView 就传了自己的),但默认值才是其它宿主真正发出去的东西,而且它同时 喂给一个纯图标按钮的 aria-label 和 title → detail.openAsFullPage,prop 覆盖行为不变。 - NavigationOverlay 的 resolvedTitle 兜底 'Record Detail' —— 这是**可见**的浮层标题, 不只是无障碍名 → detail.recordDetail。 - sr-only 的 SheetDescription/DialogDescription 文案 `Record detail overlay for {title}.` 原本抄了三份(drawer/modal/popover),现在合成一条带 {{title}} 占位符的 detail.recordDetailOverlay。 复数项走本仓既有的**双 key** 约定(detail.reactionCount/reactionCountOne、 detail.relatedRecords/relatedRecordOne),不用 i18next 的 _one/_other 后缀: zh/ja/ko 没有独立单数形式,那几个包会合理地缺 _one 半边, all-locales-key-parity 会把它读成丢键。 插值传的是**格式化后**的计数(1.2k,不是 1200),让无障碍名和可见数字始终一致; 同时 i18next 在 count 为字符串时会跳过自己的复数解析(needsPluralHandling = count !== undefined && !isString(count)),所以选单复数的仍然是双 key 方案。 两个组件从 useSafeTranslate 换成 createSafeTranslation:新增的两条 key 需要插值, 而 useSafeTranslate 的签名里没有 options bag。defaults map 同时是无 provider 时 English 兜底的来源 —— 包外消费者依赖这一点:plugin-view 的 ObjectView.test.tsx 用 getByLabelText('Close panel')、e2e/live/inline-edit-polish-2572.spec.ts 用英文 无障碍名定位表头工具条,两者都没挂 I18nProvider。 六条新词条十个语言包全补。 测试方向(先预测后验证):把两个源文件回滚到 origin/main、保留新测试后, en 用例仍全绿(这正是要保住的英文兜底),zh/ja/ko/de 用例全红; 页签徽标的 en 用例也红,因为旧代码根本没有单数形式("1 items")。 无 provider 那个文件里,NavigationOverlay 的断言前后都绿 —— 这是意料之中的方向, 不是漏测:它钉的就是"英文兜底不许被 t() 吃掉";该文件唯一由红转绿的是单数徽标。 无 provider 的断言必须单独成文件:createI18n 调 instance.use(initReactI18next), 而 initReactI18next 会把该实例注册成 react-i18next 的模块级默认实例, 卸载和 cleanup() 都不会撤销。同一文件里只要先挂过一次 I18nProvider, 之后所有"无 provider"渲染都会静默落到那个语言上(第一版就在 de 上撞了)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .changeset/overlay-tabs-chrome-i18n-5506.md | 46 ++++ .../chrome-i18n-no-provider-fallback.test.tsx | 130 +++++++++++ .../navigation-overlay-chrome-i18n.test.tsx | 209 ++++++++++++++++++ .../page-tabs-count-badge-i18n.test.tsx | 127 +++++++++++ .../src/custom/navigation-overlay.tsx | 88 ++++++-- .../src/renderers/layout/containers.tsx | 41 +++- packages/i18n/src/locales/ar.ts | 6 + packages/i18n/src/locales/de.ts | 6 + packages/i18n/src/locales/en.ts | 13 ++ packages/i18n/src/locales/es.ts | 6 + packages/i18n/src/locales/fr.ts | 6 + packages/i18n/src/locales/ja.ts | 6 + packages/i18n/src/locales/ko.ts | 6 + packages/i18n/src/locales/pt.ts | 6 + packages/i18n/src/locales/ru.ts | 6 + packages/i18n/src/locales/zh.ts | 6 + 16 files changed, 686 insertions(+), 22 deletions(-) create mode 100644 .changeset/overlay-tabs-chrome-i18n-5506.md create mode 100644 packages/components/src/__tests__/chrome-i18n-no-provider-fallback.test.tsx create mode 100644 packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx create mode 100644 packages/components/src/__tests__/page-tabs-count-badge-i18n.test.tsx diff --git a/.changeset/overlay-tabs-chrome-i18n-5506.md b/.changeset/overlay-tabs-chrome-i18n-5506.md new file mode 100644 index 0000000000..3debd5dd7c --- /dev/null +++ b/.changeset/overlay-tabs-chrome-i18n-5506.md @@ -0,0 +1,46 @@ +--- +'@object-ui/components': patch +'@object-ui/i18n': patch +--- + +Localize the record-overlay and tab-badge chrome that #5430's sweep left behind (objectstack#5506) + +Four more console-chrome strings were still hardcoded English literals. Unlike +#5430's set they are not all accessible names — one is visible copy, and one was +a component **default** that only the console happened to override. + +- `page:tabs`' count badge built its `aria-label` by template literal, + `` `${formatTabCount(count)} items` ``. The badge renders digits only, so that + label *is* the badge to a screen reader — and the English plural was baked in + with no singular branch at all, so a related list with one row announced + "1 items". Now `common.itemCount` / `common.itemCountOne`. +- `NavigationOverlay`'s drag-resize handle (`role="separator"`, no visible label) + — now `common.resizeDrawer`. +- `NavigationOverlay`'s `expandLabel` **default**. Hosts may override it and the + console does, but the default is what every other host ships — and it feeds + both `aria-label` and `title` of an icon-only button. Now + `detail.openAsFullPage`, still overridable by the prop. +- `NavigationOverlay`'s `resolvedTitle` fallback, `'Record Detail'` — **visible** + overlay heading, not just an a11y name. Now `detail.recordDetail`. +- The sr-only `SheetDescription`/`DialogDescription` prose + `Record detail overlay for {title}.`, which existed in three copies + (drawer / modal / popover) — now one `detail.recordDetailOverlay` key with a + `{{title}}` placeholder. + +The count badge follows this repo's **two-key** plural convention +(`detail.reactionCount`/`reactionCountOne`, `detail.relatedRecords`/`relatedRecordOne`) +rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular +form, so those packs would legitimately omit the `_one` half and +`all-locales-key-parity` would read that as a lost key. The formatted count +(`1.2k`, not `1200`) is interpolated so the accessible name and the visible +digits never disagree — and because i18next skips its own plural resolution when +`count` is a string, the two-key scheme stays in charge of the choice. + +Both touched components moved from `useSafeTranslate` to `createSafeTranslation`, +which carries an options bag (two of the new keys interpolate) and an English +defaults map. That map is what keeps the provider-less path English, which +consumers outside this package depend on — `plugin-view`'s `ObjectView.test.tsx` +and `e2e/live/inline-edit-polish-2572.spec.ts` address this chrome by English +accessible name with no `I18nProvider` mounted. + +All six new keys are added to all ten locale packs. diff --git a/packages/components/src/__tests__/chrome-i18n-no-provider-fallback.test.tsx b/packages/components/src/__tests__/chrome-i18n-no-provider-fallback.test.tsx new file mode 100644 index 0000000000..6694dcf446 --- /dev/null +++ b/packages/components/src/__tests__/chrome-i18n-no-provider-fallback.test.tsx @@ -0,0 +1,130 @@ +/** + * 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. + */ + +/** + * Every chrome string objectstack#5506 moved into the locale packs still + * resolves to ENGLISH when no `I18nProvider` is mounted. + * + * This is not a nice-to-have: consumers outside this package address exactly + * these controls by their English accessible names with no provider in the + * tree — `packages/plugin-view/src/__tests__/ObjectView.test.tsx` + * (`getByLabelText('Close panel')`) and `e2e/live/inline-edit-polish-2572.spec.ts` + * (the header toolbar by English name). Routing a literal through `t()` without + * a working default is exactly how that breaks, and it breaks in another + * package's suite, not this one's. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` + * registers that instance as **react-i18next's module-global default**. The + * registration survives unmount and `cleanup()`. So the moment any test in a + * file mounts ``, every later + * "no provider" render in that same file silently resolves against the German + * instance — a green-looking file that asserts nothing about the fallback, or + * (as first written) a confusing red where the drawer renders + * "Als ganze Seite öffnen" under a test that mounted no provider at all. + * + * Vitest's `dom` project runs with `isolate: true`, so a file that never mounts + * a provider gets a genuinely clean global. Keep it that way: **do not import + * or mount `I18nProvider` here.** + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { SchemaRenderer } from '@object-ui/react'; +import { NavigationOverlay } from '../custom/navigation-overlay'; +// Registers the renderers at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout`. See +// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). +import '../renderers'; + +const record = { _id: 'rec_1', name: 'Acme Corp' }; + +afterEach(() => cleanup()); + +describe('NavigationOverlay chrome — English fallback with no provider (objectstack#5506)', () => { + it('names the resize handle, expand button, heading and description in English', () => { + render( + {}} + setIsOpen={() => {}} + storageKey="drawer-width:lead" + onExpand={() => {}} + > + {() =>
BODY CONTENT
} +
, + ); + + // Drag handle — `role="separator"`, no visible label. + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Resize drawer'); + // Expand button — icon-only; `title` is the precise handle because the + // shadcn Sheet primitive contributes an untranslated close of its own. + expect(screen.getByTitle('Open as full page').getAttribute('aria-label')).toBe( + 'Open as full page', + ); + // Visible heading, with no host-supplied title. + expect(screen.getByText('Record Detail')).toBeTruthy(); + // sr-only description, interpolating that same default heading. + expect(screen.getByText('Record detail overlay for Record Detail.')).toBeTruthy(); + }); + + it('keeps the #5430 close names English too', () => { + render( + {}} + setIsOpen={() => {}} + title="Acme Corp" + mainContent={
main
} + > + {() =>
BODY CONTENT
} +
, + ); + + // The exact query `plugin-view`'s ObjectView.test.tsx uses. + expect(screen.getByLabelText('Close panel')).toBeTruthy(); + }); +}); + +describe('page:tabs count badge — English fallback with no provider (objectstack#5506)', () => { + it('names the badge in English, with a real singular form', () => { + render( + , + ); + + expect(screen.getByLabelText('5 items')).toBeTruthy(); + // Pre-#5506 this read "1 items" — the English plural was baked into the + // template literal with no singular branch at all. + expect(screen.getByLabelText('1 item')).toBeTruthy(); + }); +}); diff --git a/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx b/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx new file mode 100644 index 0000000000..ae8e6cf234 --- /dev/null +++ b/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx @@ -0,0 +1,209 @@ +/** + * 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 rest of the record overlay's chrome speaks the session locale — + * objectstack#5506 (follow-on to #5430, which covered only the two close + * affordances). + * + * Four more strings were hardcoded English literals in `NavigationOverlay`: + * + * - the drag-resize handle's `aria-label` on `role="separator"`. The handle + * has no visible label at all, so the literal WAS the control. + * - `expandLabel`'s **default**. Callers may override it (the console's + * `ObjectView` does), but the default is what ships to every other host — + * and it feeds both `aria-label` and `title` of an icon-only button. + * - `resolvedTitle`'s fallback, `'Record Detail'`. This one is **visible** + * copy, not just an accessible name. + * - the sr-only `SheetDescription`/`DialogDescription` prose, which existed + * in three copies (drawer / modal / popover) and interpolates the title. + * + * Addressing convention, inherited from `navigation-overlay-close-i18n.test.tsx`: + * the shadcn `Sheet` primitive auto-renders a close button whose only name is a + * hardcoded English `sr-only` span (an upstream No-Touch zone, AGENTS.md #7). + * `NavigationOverlay` CSS-hides it, but happy-dom applies no Tailwind, so it is + * still in the tree — never address anything here by a bare name of "Close". + * + * Direction of these assertions: `en` was already green before the change (the + * English default has to survive), and every non-English case was red. The + * provider-less fallback is asserted in `chrome-i18n-no-provider-fallback.test.tsx` + * — it cannot live in this file, see that file's header for why. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { NavigationOverlay } from '../custom/navigation-overlay'; + +const record = { _id: 'rec_1', name: 'Acme Corp' }; + +type DrawerOpts = { + /** Omitted on purpose in the default-title cases. */ + title?: string; + /** Presence of a storageKey is what renders the drag-resize handle. */ + storageKey?: string; + onExpand?: () => void; + expandLabel?: string; +}; + +function renderDrawer(language: string, opts: DrawerOpts = {}) { + return render( + + {}} + setIsOpen={() => {}} + {...opts} + > + {/* Deliberately NOT the record's name: the header title renders it too, + and a body echo makes `getByText(title)` ambiguous. */} + {() =>
BODY CONTENT
} +
+
, + ); +} + +afterEach(() => cleanup()); + +describe('NavigationOverlay drag-resize handle — accessible name (objectstack#5506)', () => { + it('still reads English under an en session', () => { + renderDrawer('en', { title: 'Acme Corp', storageKey: 'drawer-width:lead' }); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Resize drawer'); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawer('zh', { title: 'Acme Corp', storageKey: 'drawer-width:lead' }); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('调整面板宽度'); + expect(screen.queryByLabelText('Resize drawer')).toBeNull(); + }); + + it('reads the de bundle value under a de session', () => { + renderDrawer('de', { title: 'Acme Corp', storageKey: 'drawer-width:lead' }); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Panelbreite anpassen'); + }); +}); + +describe('NavigationOverlay expand button — default label (objectstack#5506)', () => { + it('still reads English under an en session', () => { + renderDrawer('en', { title: 'Acme Corp', onExpand: () => {} }); + + // Ours is the only expand affordance, and it carries both name and title. + expect(screen.getByTitle('Open as full page').getAttribute('aria-label')).toBe( + 'Open as full page', + ); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawer('zh', { title: 'Acme Corp', onExpand: () => {} }); + + expect(screen.getByTitle('以完整页面打开').getAttribute('aria-label')).toBe('以完整页面打开'); + expect(screen.queryByTitle('Open as full page')).toBeNull(); + }); + + it('reads the ja bundle value under a ja session', () => { + renderDrawer('ja', { title: 'Acme Corp', onExpand: () => {} }); + + expect(screen.getByTitle('フルページで開く')).toBeTruthy(); + }); + + /** + * The prop is still an override, not merely a default hint — the console + * passes its own `console.objectView.expandToPage` value through it, and that + * must keep winning over the component's own key. + */ + it("a host-supplied expandLabel still wins over the locale's default", () => { + renderDrawer('zh', { + title: 'Acme Corp', + onExpand: () => {}, + expandLabel: '主机自定义标签', + }); + + expect(screen.getByTitle('主机自定义标签')).toBeTruthy(); + expect(screen.queryByTitle('以完整页面打开')).toBeNull(); + }); +}); + +describe('NavigationOverlay default heading — visible copy (objectstack#5506)', () => { + it('still reads English under an en session', () => { + renderDrawer('en'); + + expect(screen.getByText('Record Detail')).toBeTruthy(); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawer('zh'); + + expect(screen.getByText('记录详情')).toBeTruthy(); + expect(screen.queryByText('Record Detail')).toBeNull(); + }); + + it('reads the ko bundle value under a ko session', () => { + renderDrawer('ko'); + + expect(screen.getByText('레코드 세부 정보')).toBeTruthy(); + }); + + it('a host-supplied title still wins in every locale', () => { + renderDrawer('zh', { title: 'Acme Corp' }); + + expect(screen.getByText('Acme Corp')).toBeTruthy(); + expect(screen.queryByText('记录详情')).toBeNull(); + }); +}); + +describe('NavigationOverlay sr-only description (objectstack#5506)', () => { + it('still reads English under an en session, interpolating the title', () => { + renderDrawer('en', { title: 'Acme Corp' }); + + expect(screen.getByText('Record detail overlay for Acme Corp.')).toBeTruthy(); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawer('zh', { title: 'Acme Corp' }); + + expect(screen.getByText('Acme Corp 的记录详情浮层。')).toBeTruthy(); + expect(screen.queryByText('Record detail overlay for Acme Corp.')).toBeNull(); + }); + + /** + * The description interpolates `resolvedTitle`, so with no host title BOTH + * halves have to come from the pack — a regression that localized the prose + * but left the title fallback English would show up here and nowhere else. + */ + it('interpolates the localized default title when the host passes none', () => { + renderDrawer('zh'); + + expect(screen.getByText('记录详情 的记录详情浮层。')).toBeTruthy(); + }); + + it('is rendered in modal mode too', () => { + render( + + {}} + setIsOpen={() => {}} + title="Acme Corp" + > + {() =>
BODY CONTENT
} +
+
, + ); + + expect(screen.getByText('Acme Corp 的记录详情浮层。')).toBeTruthy(); + }); +}); diff --git a/packages/components/src/__tests__/page-tabs-count-badge-i18n.test.tsx b/packages/components/src/__tests__/page-tabs-count-badge-i18n.test.tsx new file mode 100644 index 0000000000..5f08e2ccf5 --- /dev/null +++ b/packages/components/src/__tests__/page-tabs-count-badge-i18n.test.tsx @@ -0,0 +1,127 @@ +/** + * 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. + */ + +/** + * `page:tabs`' count badge announces itself in the session locale — + * objectstack#5506. + * + * The badge renders DIGITS ONLY, so its `aria-label` is the badge as far as a + * screen reader is concerned. That label used to be a template literal, + * `` `${formatTabCount(item.count)} items` `` — hardcoded English *and* + * English-pluralized by construction (no singular form at all: a related list + * with one row announced "1 items"). + * + * It now reads `common.itemCount` / `common.itemCountOne` — the repo's two-key + * plural convention, NOT an i18next `_one`/`_other` pair, because zh/ja/ko have + * no separate singular form and `all-locales-key-parity` would read the + * legitimately-absent `_one` half as a lost key. + * + * Direction: the `en` plural case was already green before the change (the + * English default has to survive); the zh and de cases were red, and the + * `1 item` singular case was red under **every** locale including `en`, because + * no singular form existed at all. The provider-less fallback is asserted in + * `chrome-i18n-no-provider-fallback.test.tsx` — it cannot live in this file, see + * that file's header for why. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRenderer } from '@object-ui/react'; +// Registers the renderers at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout`. See +// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). +import '../renderers'; + +/** + * Two tabs minimum: the strip is hidden entirely at length 1 (a lone "Details" + * pill is clutter, not an affordance), and with no strip there is no badge. + */ +const tabsSchema = (items: any[]) => ({ type: 'page:tabs', id: 'tabs', items }); + +const textChild = (content: string) => [{ type: 'element:text', properties: { content } }]; + +function renderTabs(language: string, items: any[]) { + return render( + + + , + ); +} + +const twoTabs = [ + { label: 'Details', value: 'details', count: 5, children: textChild('DETAILS BODY') }, + { label: 'Related', value: 'related', count: 1, children: textChild('RELATED BODY') }, +]; + +afterEach(() => cleanup()); + +describe('page:tabs count badge — accessible name (objectstack#5506)', () => { + it('still reads English under an en session', () => { + const { getByLabelText } = renderTabs('en', twoTabs); + + expect(getByLabelText('5 items')).toBeTruthy(); + // The singular half: `1 items` was the pre-change output in EVERY locale. + expect(getByLabelText('1 item')).toBeTruthy(); + expect(getByLabelText('1 item').textContent).toBe('1'); + }); + + it('reads the zh bundle values under a zh session', () => { + const { getByLabelText, queryByLabelText } = renderTabs('zh', twoTabs); + + expect(getByLabelText('5 项')).toBeTruthy(); + expect(getByLabelText('1 项')).toBeTruthy(); + expect(queryByLabelText('5 items')).toBeNull(); + expect(queryByLabelText('1 items')).toBeNull(); + }); + + /** + * German is the load-bearing plural case: unlike zh it HAS a distinct + * singular, so this is where a collapsed one-key scheme would show up. + */ + it('splits singular and plural under a de session', () => { + const { getByLabelText } = renderTabs('de', twoTabs); + + expect(getByLabelText('5 Elemente')).toBeTruthy(); + expect(getByLabelText('1 Element')).toBeTruthy(); + }); + + it('reads the ja bundle values under a ja session', () => { + const { getByLabelText } = renderTabs('ja', twoTabs); + + expect(getByLabelText('5 件')).toBeTruthy(); + }); +}); + +describe('page:tabs count badge — formatted counts (objectstack#5506)', () => { + /** + * `formatTabCount` shortens >= 1000 to `1.2k`. The accessible name must carry + * the SAME string the badge shows, so a screen-reader user and a sighted user + * are told the same thing — and interpolating a formatted (string) value is + * also what keeps i18next's own plural resolver out of the decision, leaving + * the two-key scheme in charge. + */ + it('interpolates the shortened count, matching the visible digits', () => { + const { getByLabelText } = renderTabs('en', [ + { label: 'Details', value: 'details', count: 1200, children: textChild('A') }, + { label: 'Related', value: 'related', count: 20000, children: textChild('B') }, + ]); + + expect(getByLabelText('1.2k items').textContent).toBe('1.2k'); + expect(getByLabelText('20k items').textContent).toBe('20k'); + }); + + it('interpolates the shortened count under a non-English session too', () => { + const { getByLabelText } = renderTabs('zh', [ + { label: 'Details', value: 'details', count: 1200, children: textChild('A') }, + { label: 'Related', value: 'related', count: 3, children: textChild('B') }, + ]); + + expect(getByLabelText('1.2k 项').textContent).toBe('1.2k'); + }); +}); diff --git a/packages/components/src/custom/navigation-overlay.tsx b/packages/components/src/custom/navigation-overlay.tsx index a5633728b5..adfeb0e436 100644 --- a/packages/components/src/custom/navigation-overlay.tsx +++ b/packages/components/src/custom/navigation-overlay.tsx @@ -66,7 +66,45 @@ import { ResizableHandle, } from './resizable'; import { usePopperAwareInteractOutside } from './mobile-dialog-content'; -import { useSafeTranslate } from '@object-ui/i18n'; +import { createSafeTranslation } from '@object-ui/i18n'; + +/** + * English defaults for every string this overlay renders as chrome + * (objectstack#5430, objectstack#5506). + * + * `createSafeTranslation` — not the per-call `useSafeTranslate` this file used + * before — because two of these keys INTERPOLATE (`recordDetailOverlay` takes + * `{{title}}`) and `useSafeTranslate`'s signature carries no options bag. The + * defaults map is also what keeps the no-provider path English: `ObjectView`'s + * unit tests and `e2e/live/inline-edit-polish-2572.spec.ts` address these + * controls by their English accessible names with no `I18nProvider` mounted. + */ +const OVERLAY_DEFAULT_TRANSLATIONS: Record = { + 'common.close': 'Close', + 'common.closePanel': 'Close panel', + // The drag handle is a bare `role="separator"` with no visible label, so + // this literal IS the control to a screen reader. + 'common.resizeDrawer': 'Resize drawer', + // Default accessible name + tooltip of the icon-only expand button. Hosts + // may override it via the `expandLabel` prop; this is what ships otherwise. + 'detail.openAsFullPage': 'Open as full page', + // VISIBLE overlay heading when the host passes no `title` — not merely an + // a11y name. + 'detail.recordDetail': 'Record Detail', + // sr-only Sheet/Dialog description used when the host passes no + // `description`. Rendered in three places (drawer / modal / popover). + 'detail.recordDetailOverlay': 'Record detail overlay for {{title}}.', +}; + +/** + * Probe key is `common.close`: present in all ten packs, so a mounted + * `I18nProvider` always resolves it and the real `t` is used; with no provider + * the probe fails and every key above falls back to its English default. + */ +const useOverlayTranslation = createSafeTranslation( + OVERLAY_DEFAULT_TRANSLATIONS, + 'common.close', +); /** Navigation mode type — matches ViewNavigationConfig.mode */ export type NavigationOverlayMode = @@ -130,7 +168,13 @@ export interface NavigationOverlayProps { * When omitted, the expand button is not rendered. */ onExpand?: () => void; - /** Optional label for the expand button (accessible name & tooltip). */ + /** + * Optional label for the expand button (accessible name & tooltip). + * + * When omitted the overlay resolves `detail.openAsFullPage` from the session + * locale (English when no `I18nProvider` is mounted) — it is no longer a + * hardcoded English literal (objectstack#5506). + */ expandLabel?: string; /** * Optional storage key for persisting the user's manually-resized drawer @@ -276,21 +320,27 @@ export const NavigationOverlay: React.FC = ({ mainContent, popoverTrigger, onExpand, - expandLabel = 'Open as full page', + expandLabel, storageKey, }) => { const widthStyle = getWidthStyle(width); - const resolvedTitle = title || 'Record Detail'; + // Every chrome string below is locale-driven (objectstack#5430 for the two + // close affordances, objectstack#5506 for the rest). Must stay above the + // conditional returns — rules-of-hooks. + const { t } = useOverlayTranslation(); + const resolvedTitle = title || t('detail.recordDetail'); + // `??` rather than a destructuring default so the semantics are unchanged + // for hosts that pass the prop: a default parameter also only fires on + // `undefined`, and `ObjectView` already supplies its own translated label. + const resolvedExpandLabel = expandLabel ?? t('detail.openAsFullPage'); + // sr-only Sheet/Dialog description, used in three modes below. + const overlayDescription = t('detail.recordDetailOverlay', { title: resolvedTitle }); // Keep hooks above all conditional returns. Opening a record changes // selectedRecord from null to an object, but hook order must stay stable. const resize = useDrawerResize(mode === 'drawer' ? storageKey : undefined); // Inline-edit dropdowns render in body-level poppers; without this guard the // click that closes an open dropdown also dismisses the drawer/modal (#2156). const handleInteractOutside = usePopperAwareInteractOutside(); - // Both close affordances below are icon-only, so their accessible name IS - // the control to a screen reader and to the hover tooltip (objectstack#5430). - // Must stay above the conditional returns — rules-of-hooks. - const tt = useSafeTranslate(); // Non-overlay modes don't render anything if (mode === 'page' || mode === 'new_window' || mode === 'none') { @@ -354,7 +404,7 @@ export const NavigationOverlay: React.FC = ({
= ({ {description} ) : ( - Record detail overlay for {resolvedTitle}. + {overlayDescription} )}
@@ -383,8 +433,8 @@ export const NavigationOverlay: React.FC = ({