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..c34f24dcc5 --- /dev/null +++ b/packages/components/src/__tests__/navigation-overlay-close-i18n.test.tsx @@ -0,0 +1,129 @@ +/** + * 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()); + +/** + * Addressing the drawer's close button: by `title`, NOT by role+name. + * + * The shadcn `Sheet` primitive auto-renders a close button of its own, whose + * only accessible name is a hardcoded English `sr-only` span + * (`packages/components/src/ui/sheet.tsx:80` — an upstream No-Touch zone, + * AGENTS.md #7). `NavigationOverlay` CSS-hides it with + * `[&>button:last-of-type]:hidden`, so a real browser drops it from the + * accessibility tree — but jsdom does not apply Tailwind, so RTL still sees it + * and `getByRole('button', { name: 'Close' })` matches two elements under `en`. + * + * Ours is the only close carrying a `title`, so that is the precise handle. The + * primitive's own untranslated label is a separate, out-of-scope finding. + */ +describe('NavigationOverlay drawer close — accessible name (objectstack#5430)', () => { + it('still reads English under an en session', () => { + renderDrawerIn('en'); + + expect(screen.getByTitle('Close').getAttribute('aria-label')).toBe('Close'); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawerIn('zh'); + + expect(screen.getByTitle('关闭').getAttribute('aria-label')).toBe('关闭'); + expect(screen.getByRole('button', { name: '关闭' })).toBeTruthy(); + // The literal this replaced, scoped to OUR button via `title` so the + // primitive's hidden English one cannot mask a re-inlined string here. + expect(screen.queryByTitle('Close')).toBeNull(); + }); + + it('reads the de bundle value under a de session', () => { + renderDrawerIn('de'); + + expect(screen.getByTitle('Schließen').getAttribute('aria-label')).toBe('Schließen'); + expect(screen.getByRole('button', { name: 'Schließen' })).toBeTruthy(); + expect(screen.queryByTitle('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) => (