From f5aee50e88000ac45e6c984bc417ea844a6b21f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:19:24 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(components,plugin-detail,i18n):=20?= =?UTF-8?q?=E6=8A=8A=E5=89=A9=E4=BD=99=E7=9A=84=E6=8E=A7=E5=88=B6=E5=8F=B0?= =?UTF-8?q?=20chrome=20=E6=97=A0=E9=9A=9C=E7=A2=8D=E5=90=8D=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20i18n=20(objectstack#5430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四处 icon-only 控件的可访问名仍是硬编码英文字面量;因为控件没有可见标签, 这个字面量对屏幕阅读器和 hover tooltip 而言就是控件本身。 - page:header 的 role="toolbar" → detail.pageHeaderActions - ReactionPicker 的 role="listbox" 弹层 → detail.emojiPicker - ReactionPicker 每个表情 chip(原本用字符串拼接内建英文复数) → detail.reactionCount / detail.reactionCountOne - NavigationOverlay 抽屉关闭 / 分栏面板关闭 → common.close / common.closePanel 复数项走本仓既有的双 key 约定(relatedRecords/relatedRecordOne、 recordCount/recordCountOne),不引入 i18next 的 _one/_other 后缀: zh/ja/ko 没有独立单数形式,那两个包会合理地缺 _one, all-locales-key-parity 会把它读成丢键。 五个新词条十个语言包全补。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .changeset/chrome-a11y-names-i18n-5430.md | 29 +++++ .../navigation-overlay-close-i18n.test.tsx | 112 ++++++++++++++++++ .../page-header-toolbar-name-i18n.test.tsx | 81 +++++++++++++ .../src/custom/navigation-overlay.tsx | 11 +- .../src/renderers/layout/containers.tsx | 2 +- packages/i18n/src/locales/ar.ts | 5 + packages/i18n/src/locales/de.ts | 5 + packages/i18n/src/locales/en.ts | 5 + packages/i18n/src/locales/es.ts | 5 + packages/i18n/src/locales/fr.ts | 5 + packages/i18n/src/locales/ja.ts | 5 + packages/i18n/src/locales/ko.ts | 5 + packages/i18n/src/locales/pt.ts | 5 + packages/i18n/src/locales/ru.ts | 5 + packages/i18n/src/locales/zh.ts | 5 + packages/plugin-detail/src/ReactionPicker.tsx | 50 +++++--- .../ReactionPicker.a11yNames.test.tsx | 112 ++++++++++++++++++ .../plugin-detail/src/useDetailTranslation.ts | 8 ++ 18 files changed, 432 insertions(+), 23 deletions(-) create mode 100644 .changeset/chrome-a11y-names-i18n-5430.md create mode 100644 packages/components/src/__tests__/navigation-overlay-close-i18n.test.tsx create mode 100644 packages/components/src/__tests__/page-header-toolbar-name-i18n.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/ReactionPicker.a11yNames.test.tsx diff --git a/.changeset/chrome-a11y-names-i18n-5430.md b/.changeset/chrome-a11y-names-i18n-5430.md new file mode 100644 index 0000000000..f8a499a940 --- /dev/null +++ b/.changeset/chrome-a11y-names-i18n-5430.md @@ -0,0 +1,29 @@ +--- +'@object-ui/components': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +Localize the last untranslated console-chrome accessible names (objectstack#5430) + +Four icon-only controls still carried hardcoded English accessible names, so +under a non-English session they were the only English left in the record +chrome — and because the controls have no visible label, that literal *is* the +control to a screen reader and to the hover tooltip. + +- `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯` + overflow trigger eight lines below was fixed in #5407; the toolbar was missed) +- `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker` +- `ReactionPicker`'s per-reaction chip, which built its name by concatenation + with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) — + now `detail.reactionCount` / `detail.reactionCountOne` +- `NavigationOverlay`'s drawer close and split-panel close — now `common.close` + (the key the rest of the console already uses) and `common.closePanel` + +The pluralized label follows this repo's **two-key** convention +(`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`) +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. + +All five new keys are added to all ten locale packs. diff --git a/packages/components/src/__tests__/navigation-overlay-close-i18n.test.tsx b/packages/components/src/__tests__/navigation-overlay-close-i18n.test.tsx new file mode 100644 index 0000000000..4c127ec9bc --- /dev/null +++ b/packages/components/src/__tests__/navigation-overlay-close-i18n.test.tsx @@ -0,0 +1,112 @@ +/** + * 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 record overlay's close affordances speak the session locale — + * objectstack#5430. + * + * `NavigationOverlay` had two hardcoded English accessible names: + * - drawer mode: the header `X` button (`aria-label`/`title` = "Close") + * - split mode: the detail panel's `X` button (`aria-label` = "Close panel") + * + * Both are icon-only, so the literal WAS the control to a screen reader and to + * the hover tooltip. They now read `common.close` / `common.closePanel`. + * `common.close` is deliberately the key the rest of the console already uses + * for a bare "Close" rather than a new overlay-private one. + */ + +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' }; + +function renderDrawerIn(language: string) { + return render( + + {}} + setIsOpen={() => {}} + title="Acme Corp" + > + {(r) =>
{String(r.name)}
} +
+
, + ); +} + +function renderSplitIn(language: string) { + return render( + + {}} + setIsOpen={() => {}} + title="Acme Corp" + mainContent={
main
} + > + {(r) =>
{String(r.name)}
} +
+
, + ); +} + +afterEach(() => cleanup()); + +describe('NavigationOverlay drawer close — accessible name (objectstack#5430)', () => { + it('still reads English under an en session', () => { + renderDrawerIn('en'); + + expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy(); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawerIn('zh'); + + expect(screen.getByRole('button', { name: '关闭' })).toBeTruthy(); + // The literal this replaced. Negative direction matters here: the shadcn + // Sheet ships its own auto-rendered close button, so a re-inlined English + // name would otherwise hide behind the positive assertion. + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + + it('reads the de bundle value under a de session', () => { + renderDrawerIn('de'); + + expect(screen.getByRole('button', { name: 'Schließen' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); +}); + +describe('NavigationOverlay split close panel — accessible name (objectstack#5430)', () => { + it('still reads English under an en session', () => { + renderSplitIn('en'); + + expect(screen.getByRole('button', { name: 'Close panel' })).toBeTruthy(); + }); + + it('reads the zh bundle value under a zh session', () => { + renderSplitIn('zh'); + + expect(screen.getByRole('button', { name: '关闭面板' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Close panel' })).toBeNull(); + }); + + it('reads the ja bundle value under a ja session', () => { + renderSplitIn('ja'); + + expect(screen.getByRole('button', { name: 'パネルを閉じる' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Close panel' })).toBeNull(); + }); +}); diff --git a/packages/components/src/__tests__/page-header-toolbar-name-i18n.test.tsx b/packages/components/src/__tests__/page-header-toolbar-name-i18n.test.tsx new file mode 100644 index 0000000000..4ba012d225 --- /dev/null +++ b/packages/components/src/__tests__/page-header-toolbar-name-i18n.test.tsx @@ -0,0 +1,81 @@ +/** + * 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 page header's action toolbar speaks the session locale — + * objectstack#5430. + * + * `page:header` wraps its action buttons in a `role="toolbar"` whose accessible + * name was the English literal "Page header actions". A toolbar has no visible + * label, so that literal IS the group as far as a screen reader is concerned: + * under a zh/ja session it announced the only English left in the header row. + * (#5407 fixed the `⋯` trigger eight lines below it and missed this one.) + * + * It now reads `detail.pageHeaderActions`. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +// Registers `page:header` at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout` (objectui#3010/#3021). +import '../renderers'; + +function PageHeader({ schema }: { schema: any }) { + const Component = ComponentRegistry.get('page:header'); + if (!Component) throw new Error('page:header not registered'); + // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render + return ; +} + +const schema = { + type: 'page:header', + title: 'Acme Corp', + actions: [ + { name: 'convert', locations: ['record_header'], label: 'Convert', type: 'flow' }, + { name: 'clone', locations: ['record_header'], label: 'Clone', type: 'flow' }, + ], +}; + +function renderHeaderIn(language: string) { + return render( + + + + + , + ); +} + +afterEach(() => cleanup()); + +describe('page:header action toolbar — accessible name (objectstack#5430)', () => { + it('still reads English under an en session', () => { + renderHeaderIn('en'); + + expect(screen.getByRole('toolbar', { name: 'Page header actions' })).toBeTruthy(); + }); + + it('reads the zh bundle value under a zh session', () => { + renderHeaderIn('zh'); + + expect(screen.getByRole('toolbar', { name: '页面标题栏操作' })).toBeTruthy(); + // The literal this replaced. Asserted negatively so a re-inlined English + // string cannot pass by rendering a second, untranslated toolbar. + expect(screen.queryByRole('toolbar', { name: 'Page header actions' })).toBeNull(); + }); + + it('reads the ja bundle value under a ja session', () => { + renderHeaderIn('ja'); + + expect(screen.getByRole('toolbar', { name: 'ページヘッダーの操作' })).toBeTruthy(); + expect(screen.queryByRole('toolbar', { name: 'Page header actions' })).toBeNull(); + }); +}); diff --git a/packages/components/src/custom/navigation-overlay.tsx b/packages/components/src/custom/navigation-overlay.tsx index ce8006bc4f..a5633728b5 100644 --- a/packages/components/src/custom/navigation-overlay.tsx +++ b/packages/components/src/custom/navigation-overlay.tsx @@ -66,6 +66,7 @@ import { ResizableHandle, } from './resizable'; import { usePopperAwareInteractOutside } from './mobile-dialog-content'; +import { useSafeTranslate } from '@object-ui/i18n'; /** Navigation mode type — matches ViewNavigationConfig.mode */ export type NavigationOverlayMode = @@ -286,6 +287,10 @@ export const NavigationOverlay: React.FC = ({ // 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') { @@ -388,8 +393,8 @@ export const NavigationOverlay: React.FC = ({ - ))} + {reactions.map((reaction) => { + // Two keys, NOT an i18next `_one`/`_other` pair — this repo's own + // plural convention (`detail.relatedRecords`/`relatedRecordOne`, + // `lookup.recordCount`/`recordCountOne`). A `_one` suffix would break + // `all-locales-key-parity`: zh/ja/ko have no separate singular form, + // so those packs would legitimately omit the `_one` half and the gate + // would read that as a missing key (objectstack#5430). + const countLabel = t( + reaction.count === 1 ? 'detail.reactionCountOne' : 'detail.reactionCount', + { emoji: reaction.emoji, count: reaction.count }, + ); + return ( + + ); + })} {/* Add reaction button */} {onToggleReaction && ( @@ -85,7 +97,7 @@ export const ReactionPicker: React.FC = ({
{emojiOptions.map((emoji) => (