Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit e723f58

Browse files
fix(i18n,a11y): the enumerated literal aria-label names read each package's translation hook, and a census pins the family (objectui#12145) (#12149)
Fixes #12145 Clause-②: yes The card's enumerated `aria-label` sites read their accessible names through their own package's translation hook, so a screen reader under `zh` (or any non-English pack) hears the viewer's language. Eight keys are added to all ten `@object-ui/i18n` packs with real translations; every other name reuses a key that already holds the same words. The family is pinned by a census test in the ordinary suite (not a CI gate), beside per-package `zh` role-and-name pins. `Clause-②: yes` is the claim's reading (comment 6110243674): the added keys widen the `as const` `en` pack, and so the published `TranslationKeys`. Confirmed on the built declarations at HEAD `ebfc833`: `packages/i18n/dist/locales/en.d.ts` declares all eight keys, and `dist/index.d.ts` re-exports `TranslationKeys` (`typeof en`). ## Every enumerated site, and what happened to it | Site (the card's 31) | Disposition | Key | |---|---|---| | `apps/console` `AppDocsIndex`, `BookPage`, `DocPage`, `DocsIndex`, `DocsSlug`: "Loading documentation" (5) | read through `useObjectTranslation` | `help.loadingDocumentation` (new) | | `app-shell` `RecordFormPage`: "Breadcrumb" | read through the page's `t` | `common.breadcrumb` (new) | | `components` `custom/spinner.tsx`: "Loading" | read through a `createSafeTranslation` hook; a caller's `aria-label` still wins | `common.loading` (reused; the English name is now "Loading…") | | `components` `debug/DebugPanel.tsx`: "Developer Debug Panel", "Close debug panel" | **exempt**: a developer tool, not product UI. No objectui surface mounts it (a host opts in behind `?__debug` / Ctrl+Shift+D), and its whole chrome is English developer copy, so translating two names would leave it half in one language | none | | `components` `renderers/complex/data-table.tsx`: "Row actions" | read through `useTableTranslation` | `table.rowActions` (new) | | `components` `ui/pagination.tsx`: "Go to previous page", "Go to next page" | the No-Touch primitive is **not edited**; its one shipped call site, the `pagination` renderer (`renderers/basic/pagination.tsx`), passes the names, which the primitive spreads after its defaults | `table.previousPage` / `table.nextPage` (reused, PR objectui#12142) | | `components` `ui/sidebar.tsx`: "Toggle Sidebar" (`SidebarRail`) | **exempt**: the No-Touch primitive is not edited, and no objectui surface renders the rail. Its only writer is the CLI's app generator, whose generated app mounts no `I18nProvider`. A host names it through `aria-label` / `title` | (`common.toggleSidebar` for a host) | | `fields` `ImageCropperDialog`: "Image crop preview" | `useFieldTranslation` | `fields.image.cropPreview` (new) | | `fields` `PercentField`: "Percentage" | `useFieldTranslation` | `fields.percent.slider` (new) | | `fields` `RecordPickerDialog`: "Records" | `useFieldTranslation` | `console.commandPalette.records` (reused) | | `plugin-chatbot` `elements/message.tsx`: "Previous branch", "Next branch", "Remove attachment" x2 | **exempt**: vendored from vercel/ai-elements (the header says wrap, do not edit), and no objectui surface renders `MessageBranchPrevious`, `MessageBranchNext` or `MessageAttachment`; they are only re-exported as `AIElements`. The branch buttons spread `props` last | none | | `plugin-chatbot` `elements/prompt-input.tsx`: "Remove attachment" (`PromptInputAttachment`) | rendered by `ChatbotEnhanced`, and `props` reach the chip, not the button. An optional `removeLabel` prop is added to the vendored element (the objectui#7254 `statusLabels` precedent: additive, the upstream English stays when omitted); `ChatbotEnhanced` passes the pack's name | `detail.removeAttachment` (reused) | | `plugin-chatbot` `elements/prompt-input.tsx`: "Submit" (`PromptInputSubmit`) | the vendored default stays (**exempt**); its one call site, `ChatbotEnhanced`, already overrode it with `L.submit`, whose fallback WAS the English literal. That producer now reads the pack, for the stop state too | `console.ai.submit` / `console.ai.stopResponse` (reused: the console already passes them) | | `plugin-designer` `HistoryPanel`: "Undo", "Redo" | `useDesignerTranslation` | `designer.undo` / `designer.redo` (reused) | | `plugin-designer` `Minimap`: "Canvas minimap" | `useDesignerTranslation` | `designer.canvasMinimap` (new) | | `plugin-designer` `VersionHistory`: "Version history" | `useDesignerTranslation` | `designer.versionHistory` (new) | | `plugin-designer` `DashboardDesignPage`: "Go back" | `useDesignerTranslation` | `detail.goBack` (reused) | | `plugin-detail` `record-alert.tsx`: "Dismiss" | the renderer's `t` | `home.recoveryReminder.dismiss` (reused) | | `plugin-detail` `record-path.tsx`: "Alternative terminal stages" | **not a site**: the card's grep matched a comment that quotes the objectui#5956 repair. The comment is reworded so the card's command no longer matches it; no attribute existed | none | | `plugin-grid` `BulkActionBar`: "Bulk actions" | the bar's `t` | `grid.bulkActions` (new) | | `plugin-list` `TabBar` (`TabBarSelect`): "Switch view" | `useObjectTranslation` | `topbar.switchView` (reused: the app header's view switcher reads it) | "Reused" follows triage's rule, one key per name: where the English name already had a key, the site borrows it rather than add a second (the "Dismiss" borrow picks the banner-dismiss key whose ten translations fit a dismissible alert). The card's re-check command, run on this branch, returns exactly the ten exempt literals above and nothing else. ## The census: `packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts` Home chosen by precedent: `machineLocaleCensus-9909.test.ts` in the same directory is the repo's existing one-test census over every package's shipped sources, with comment masking through `scripts/js-comment-mask.mjs` and declared exemptions cited by content. This census copies that shape. - **Population:** every source file under a `src/` tree of a `packages/*` or `apps/*` workspace, minus tests, stories and tooling. Walked, not globbed, plus a guard that the files directly under a `src/` root are in it (see the finding below). - **Shapes judged:** `aria-label="…"`, `aria-label={'…'}` / `{"…"}` (the widening triage asked for), a braced template that interpolates nothing, and the `'aria-label': '…'` object key, when the value carries a letter (`\p{L}`, so a name in any script counts). - **Verdict:** each literal is declared by file and name, counted exactly. A new literal is red, and so is a declared one that disappears, so its entry leaves in the same commit. The ten exempt sites carry their reasons; everything else literal is a frozen BACKLOG (next section). - **Stated blind spots** (in the test header): interpolating templates, a literal inside a larger expression, `title=` / `placeholder=` / `sr-only` / renamed props, and JSX text that the comment masker reads as a comment opener. Readings (the census as committed, run on each tree): | Tree | Literal sites found | Undeclared | Verdict | |---|---|---|---| | BASE `158c75d` site files, packs and tests at HEAD (Leg A below) | 117 | 20, exactly the card's translated sites | red | | HEAD `ebfc833` | 97 (10 exempt + 87 backlog) | 0 | green | ## Finding: the card's re-check pathspec never read a file directly under `src/` `git grep … -- 'packages/*/src/**/*.tsx'` has no `:(glob)` magic, so its pattern needs a `/` after `src/`: no file directly under a `src/` root was ever read (the control: `aria-label="Undo"` in `plugin-designer/src/DataModelDesigner.tsx` is not returned, while the same string in `src/components/HistoryPanel.tsx` is). The same regex with `:(glob)` returned 114 sites on BASE, not 31. This PR translates the card's enumeration, per the claim's file surface; the 87 literal sites it did not enumerate (23 files, 11 packages, `plugin-designer` the bulk of them) are frozen in the census's BACKLOG, so none can grow and each must leave the list in the commit that translates it. They are reported to the seat for slicing; `auth`, `layout` and `plugin-map` among them do not depend on `@object-ui/i18n` today. ## Pins: one `zh` role-and-name query per package, each read against the pack - `apps/console/src/pages/docsLoadingName-12145.test.tsx`: the docs index spinner under en and zh. The spinner is a lucide `svg`, which testing-library's role table gives no implicit role, so this one is found by its accessible name (`getByLabelText`). - `packages/components/src/__tests__/ariaLabelNames.zh-12145.test.tsx`: `Spinner` (`status`), the data table's row menu trigger (`button`), the `pagination` renderer's links (`link`), with no provider (en) and under zh, and the primitive's English defaults absent under zh. - `packages/fields/src/__tests__/ariaLabelNames.zh-12145.test.tsx`: the percent slider (`slider`). - `packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.composerNames.zh-12145.test.tsx`: the send button with no host `labels`, and a host label still winning. - `packages/plugin-designer/src/__tests__/ariaLabelNames.zh-12145.test.tsx`: undo / redo (`button`), minimap (`img`), version history (`region`). - `packages/plugin-grid/src/__tests__/BulkActionBar.regionName.zh-12145.test.tsx`: the bar (`region`). - `packages/plugin-list/src/components/__tests__/TabBarSelect.triggerName.zh-12145.test.tsx`: the trigger (`button`). - `packages/plugin-detail/src/renderers/__tests__/record-alert.test.tsx`: one added case, the dismiss button (`button`). - `packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx`: its breadcrumb lookup read the English literal; it now reads `common.breadcrumb` for the language under test, so its zh-CN case is the package's zh pin (`navigation`). Each zh case first asserts the zh and en values differ, or it would pin nothing. ## Evidence (HEAD `ebfc833`, one locked run, every leg restored) | Leg | Mutation (landing proven on disk) | Result | |---|---|---| | 0, HEAD | none | census + 9 pin files: 10 files, 48 passed | | A, BASE sites | the 24 site files replaced by their BASE blobs, packs and tests at HEAD; literal names on disk 0 to 20 | 10 files failed, 13 failed / 35 passed: the census lists exactly the 20 translated sites as `declared 0, found 1`; every pin's zh case red (`Unable to find role="region" and name "批量操作"` and the like); the components en case red (`"Loading…"`) | | B, one site | `BulkActionBar`'s `t(…)` back to `aria-label={'Bulk actions'}` (the braced shape) via objectstack's `scripts/ablation-replace.mjs`: anchor 1 to 0, blob `c9c2d24` to `05ef07e` | census red (`BulkActionBar.tsx · Bulk actions — declared 0, found 1`), grid zh pin red | | C, backlog shrink | `ListView`'s backlog `aria-label="Bulk actions"` removed (anchor 1 to 0, blob `db03dae` to `11c9b6a`), its entry left | census red (`ListView.tsx · Bulk actions — declared 1, found 0`) | After Leg A, `git diff HEAD` was empty and the status clean; Legs B and C each restored to the HEAD blob with `git diff HEAD` empty; the run ended with the tree clean at `ebfc833`. ## Gates (all at HEAD `ebfc833`) - **Build:** the console's dependency closure, `turbo run build --filter=@object-ui/console^... --concurrency=2`: 34 of 34 tasks; then the console itself: 35 of 35. - **Type-check** (`type-check`, each script name echoed): `i18n`, `components`, `fields`, `plugin-grid`, `plugin-list`, `plugin-chatbot`, `plugin-designer`, `plugin-detail`, `app-shell`, `console`: all exit 0. Each new or edited test file is in its package's type-checked program (`tsconfig.test.json`, or the console's `tsconfig.json`). - **Tests,** repo-root form: the census and pins above; `packages/i18n/` plus `scripts/__tests__/check-i18n-call-site-keys.test.ts`: 84 files passed; the 130 existing test files that import or query a changed module, in two runs: 69 and 61 files passed. - **i18n family:** `check:i18n-keys`, `check:i18n-drift` (8 keys added, 0 `en` values changed), `check:i18n-dead-keys` (report; none of the eight, nor any reused key, listed), `check:i18n-designer-parity`, `check:eager-locale-catalogues` (only `en` eager), `check:eager-closure`: all exit 0. - **Others,** all exit 0: `check-changeset-presence.mjs` (44 source files of 10 released packages, 1 changeset), `changeset:check`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:phantom-deps`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`; governed-surface guard: NOT GOVERNED. - **Lint** on the 34 touched source and test files: 0 errors; 147 warnings, none on a changed line. - `main` has not moved since BASE `158c75d`, so no merge was needed. No docs page quotes these names, so none changed. ## Acceptance notes (outside the card, not edited here; handed to the seat) - **Interpolating templates** carry English words the census cannot judge by design, for example ``aria-label={`Dismiss ${notification.title}`}`` in the three notification surfaces, ``Remove ${name}`` in `TagsField` / `SelectionTray`, ``Copy ${label ?? 'value'}`` in two console developer pages. 30 such sites on BASE. - **Visible English beside a translated name:** the Shadcn `PaginationPrevious` / `PaginationNext` render visible "Previous" / "Next" spans, and `SidebarTrigger` (rendered bare by the `header-bar` renderer) names itself through an English `sr-only` span. Code read only. - `ChatbotEnhanced`'s `uploadFiles` fallback is still the English literal, the same shape as the `submit` fallback fixed here; the console passes its own label. - The docs pages' visible headings and empty states are English copy; out of this card, which is about accessible names. Session: `https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK` --- _Generated by [Claude Code](https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f962cdc commit e723f58

45 files changed

Lines changed: 1112 additions & 30 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/i18n': minor
3+
'@object-ui/plugin-chatbot': minor
4+
'@object-ui/components': patch
5+
'@object-ui/fields': patch
6+
'@object-ui/app-shell': patch
7+
'@object-ui/plugin-designer': patch
8+
'@object-ui/plugin-detail': patch
9+
'@object-ui/plugin-grid': patch
10+
'@object-ui/plugin-list': patch
11+
'@object-ui/console': patch
12+
---
13+
14+
Screen readers hear the names of more controls in the viewer's language (objectui#12145).
15+
16+
These controls carried an English `aria-label`, so under a non-English locale a screen reader announced their names in English: the docs portal's loading spinner, the record form page's breadcrumb, the `Spinner` status region, a data table's row menu button, the `pagination` renderer's previous and next links, the image crop preview, the percent slider, the record picker's grid, the AI composer's send and stop button and an attachment's remove button, the designer's undo and redo buttons, minimap, version history and back button, a dismissible record alert's close button, the bulk-action bar, and the mobile view switcher. Each name now comes from the language packs. English names are unchanged, with two small exceptions: the spinner now reads "Loading…", and the pagination links read "Previous page" and "Next page", the same words the data table pager uses. Sighted use is unchanged.
17+
18+
Added: eight language-pack keys in all ten packs: `help.loadingDocumentation`, `common.breadcrumb`, `table.rowActions`, `fields.image.cropPreview`, `fields.percent.slider`, `designer.canvasMinimap`, `designer.versionHistory` and `grid.bulkActions`. Every other name reuses a key that already holds the same words. The exported `en` pack, and so `TranslationKeys`, gains these eight members. `PromptInputAttachment` (exported through `AIElements`) takes an optional `removeLabel` prop that names its remove button; without it the button keeps its English name.

‎apps/console/src/pages/AppDocsIndex.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useMemo } from 'react';
1010
import { Link, Navigate, useParams } from 'react-router-dom';
1111
import { BookOpen, Loader2 } from 'lucide-react';
12+
import { useObjectTranslation } from '@object-ui/i18n';
1213
import { DocShell } from './DocShell';
1314
import { useBookData } from './use-book-data';
1415
import { buildBookCards, pkgOfBook, type Book } from './book-nav';
@@ -26,6 +27,7 @@ import { buildBookCards, pkgOfBook, type Book } from './book-nav';
2627
*/
2728
export default function AppDocsIndex() {
2829
const { appName } = useParams<{ appName?: string }>();
30+
const { t } = useObjectTranslation();
2931
const { books, docs, state } = useBookData();
3032

3133
const myBooks = useMemo<Book[]>(
@@ -37,7 +39,7 @@ export default function AppDocsIndex() {
3739
if (state === 'loading') {
3840
return (
3941
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
40-
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
42+
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
4143
</div>
4244
);
4345
}

‎apps/console/src/pages/BookPage.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useMemo } from 'react';
1010
import { Navigate, useParams } from 'react-router-dom';
1111
import { BookOpen, FileQuestion, Loader2, Lock } from 'lucide-react';
12+
import { useObjectTranslation } from '@object-ui/i18n';
1213
import { DocShell } from './DocShell';
1314
import { useBookData } from './use-book-data';
1415
import { resolveBookTree, bookSlug, firstDoc } from './book-nav';
@@ -43,6 +44,7 @@ export function DocRefusal({ name, message }: { name?: string; message?: string
4344
*/
4445
export default function BookPage() {
4546
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
47+
const { t } = useObjectTranslation();
4648
const { books, docs, state } = useBookData();
4749

4850
const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
@@ -54,7 +56,7 @@ export default function BookPage() {
5456
if (state === 'loading') {
5557
return (
5658
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
57-
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
59+
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
5860
</div>
5961
);
6062
}

‎apps/console/src/pages/DocPage.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export default function DocPage() {
174174
if (state === 'loading') {
175175
return (
176176
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
177-
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
177+
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
178178
</div>
179179
);
180180
}

‎apps/console/src/pages/DocsIndex.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useMemo } from 'react';
1010
import { Link } from 'react-router-dom';
1111
import { AlertCircle, BookOpen, Loader2 } from 'lucide-react';
12+
import { useObjectTranslation } from '@object-ui/i18n';
1213
import { DocShell } from './DocShell';
1314
import { useBookData } from './use-book-data';
1415
import { buildBookCards } from './book-nav';
@@ -23,6 +24,7 @@ import { buildBookCards } from './book-nav';
2324
* reader lives at `/docs/<slug>/<name>`.
2425
*/
2526
export default function DocsIndex() {
27+
const { t } = useObjectTranslation();
2628
const { books, docs, state, error } = useBookData();
2729

2830
// Cards carry the book's url slug so links match the `/docs/:slug` route.
@@ -31,7 +33,7 @@ export default function DocsIndex() {
3133
if (state === 'loading') {
3234
return (
3335
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
34-
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
36+
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
3537
</div>
3638
);
3739
}

‎apps/console/src/pages/DocsSlug.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useEffect, useState } from 'react';
1010
import { Navigate, useParams } from 'react-router-dom';
1111
import { FileQuestion, Loader2 } from 'lucide-react';
12+
import { useObjectTranslation } from '@object-ui/i18n';
1213
import { useAdapter } from '@object-ui/app-shell';
1314
import { DocShell } from './DocShell';
1415
import BookPage, { DocRefusal } from './BookPage';
@@ -70,6 +71,7 @@ async function refusalFor(
7071
*/
7172
export default function DocsSlug() {
7273
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
74+
const { t } = useObjectTranslation();
7375
const { books, docs, state } = useBookData();
7476
const adapter = useAdapter();
7577

@@ -97,7 +99,7 @@ export default function DocsSlug() {
9799
if (state === 'loading' || (unanswered && !!adapter && answer?.slug !== slug)) {
98100
return (
99101
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
100-
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
102+
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
101103
</div>
102104
);
103105
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#12145 — the docs portal's loading spinner is named from the pack.
11+
*
12+
* Five docs pages (`DocsIndex`, `AppDocsIndex`, `BookPage`, `DocsSlug`,
13+
* `DocPage`) drew the same spinner with `aria-label="Loading documentation"`,
14+
* so a screen reader under `zh` heard English. Each now reads
15+
* `help.loadingDocumentation`. The portal index stands in for the five: same
16+
* line, same key.
17+
*
18+
* The spinner is a lucide `<svg>`, and testing-library's role table gives an
19+
* `<svg>` no implicit role, so it is found by its accessible name through
20+
* `getByLabelText` — the name is what a screen reader announces.
21+
*/
22+
import '@testing-library/jest-dom/vitest';
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import { render, screen, cleanup } from '@testing-library/react';
25+
import { MemoryRouter } from 'react-router-dom';
26+
import { I18nProvider } from '@object-ui/i18n';
27+
import { en, zh } from '@object-ui/i18n/locales';
28+
29+
vi.mock('./use-book-data', () => ({
30+
useBookData: () => ({ books: [], docs: [], state: 'loading', error: null }),
31+
}));
32+
33+
import DocsIndex from './DocsIndex';
34+
35+
afterEach(cleanup);
36+
37+
function renderIn(language: string) {
38+
return render(
39+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
40+
<MemoryRouter>
41+
<DocsIndex />
42+
</MemoryRouter>
43+
</I18nProvider>,
44+
);
45+
}
46+
47+
describe('the docs portal names its loading spinner from the pack (objectui#12145)', () => {
48+
it('under en, the spinner is named by the en pack', async () => {
49+
renderIn('en');
50+
expect(await screen.findByLabelText(en.help.loadingDocumentation)).toBeInTheDocument();
51+
});
52+
53+
it('under zh, the spinner is named by the zh pack, not by the English words', async () => {
54+
// The pin is only a pin if the two packs disagree.
55+
expect(zh.help.loadingDocumentation).not.toBe(en.help.loadingDocumentation);
56+
renderIn('zh');
57+
expect(await screen.findByLabelText(zh.help.loadingDocumentation)).toBeInTheDocument();
58+
expect(screen.queryByLabelText(en.help.loadingDocumentation)).toBeNull();
59+
});
60+
});

‎packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3636
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
3737
import { MemoryRouter, Routes, Route } from 'react-router-dom';
3838
import { createI18n, I18nProvider } from '@object-ui/i18n';
39+
import { en, zh } from '@object-ui/i18n/locales';
3940
import { RecordFormPage } from './RecordFormPage';
4041

4142
const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }));
@@ -85,6 +86,9 @@ vi.mock('../providers/AdapterProvider', () => ({
8586

8687
type Lang = 'en' | 'zh-CN';
8788

89+
/** The breadcrumb landmark's name in each language under test, read from the pack. */
90+
const CRUMB_NAME: Record<Lang, string> = { en: en.common.breadcrumb, 'zh-CN': zh.common.breadcrumb };
91+
8892
/** The catalog as the console loads it: `zh-CN` translates both scalars. */
8993
const BUNDLE: Record<Lang, Record<string, unknown>> = {
9094
en: {},
@@ -119,8 +123,11 @@ async function pageText(lang: Lang, mode: 'create' | 'edit', objectName: string)
119123
</I18nProvider>,
120124
);
121125
const title = (await screen.findByTestId('record-form-page-title')).textContent ?? '';
122-
// The breadcrumb's one link is the way to the object's list.
123-
const crumb = screen.getByRole('navigation', { name: 'Breadcrumb' });
126+
// The breadcrumb's one link is the way to the object's list. The landmark
127+
// is found by the name the language under test's PACK gives it
128+
// (`common.breadcrumb`, objectui#12145): under zh-CN that is the zh pack's
129+
// word, so this lookup is also the package's zh role-and-name pin.
130+
const crumb = screen.getByRole('navigation', { name: CRUMB_NAME[lang] });
124131
const link = crumb.querySelector('a');
125132
expect(link?.getAttribute('href')).toBe(`/apps/demo/${objectName}`);
126133
fireEvent.click(screen.getByTestId('fire-success'));

‎packages/app-shell/src/views/RecordFormPage.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
329329
>
330330
<ArrowLeft className="h-4 w-4" />
331331
</Button>
332-
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm text-muted-foreground">
332+
<nav aria-label={t('common.breadcrumb')} className="flex items-center gap-2 text-sm text-muted-foreground">
333333
<Link
334334
to={objectListUrl}
335335
className="hover:text-foreground transition-colors"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#12145 — `@object-ui/components`' accessible names are read from
11+
* the pack, so under `zh` a screen reader hears the zh pack's words.
12+
*
13+
* - `Spinner`'s status region: `common.loading` (was `"Loading"`).
14+
* - the data table's row menu trigger: `table.rowActions` (was `"Row actions"`).
15+
* - the `pagination` renderer's previous / next links: `table.previousPage` /
16+
* `table.nextPage`. The No-Touch Shadcn primitive keeps its English
17+
* defaults; this call site names the links, since the primitive spreads
18+
* `props` after them.
19+
*
20+
* Every name is read against the PACK, never against a literal, and each zh
21+
* case first proves the two packs disagree, or it would pin nothing.
22+
*/
23+
import { describe, it, expect, afterEach } from 'vitest';
24+
import { render, screen, cleanup } from '@testing-library/react';
25+
import '@testing-library/jest-dom';
26+
import React from 'react';
27+
import { ComponentRegistry } from '@object-ui/core';
28+
import { PredicateScopeProvider } from '@object-ui/react';
29+
import { I18nProvider } from '@object-ui/i18n';
30+
import { en, zh } from '@object-ui/i18n/locales';
31+
import { Spinner } from '../custom/spinner';
32+
// Registers `data-table` and `pagination`.
33+
import '../renderers/complex/data-table';
34+
import '../renderers/basic/pagination';
35+
36+
afterEach(cleanup);
37+
38+
const inZh = (node: React.ReactNode) => (
39+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
40+
{node}
41+
</I18nProvider>
42+
);
43+
44+
function rowMenuTable() {
45+
const DataTable = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }>;
46+
return (
47+
<PredicateScopeProvider scope={{}}>
48+
<DataTable
49+
schema={{
50+
type: 'data-table',
51+
pagination: false,
52+
searchable: false,
53+
rowActions: true,
54+
columns: [{ header: 'Name', accessorKey: 'name' }],
55+
data: [{ id: 'r1', name: 'Ada' }],
56+
onRowEdit: () => {},
57+
}}
58+
/>
59+
</PredicateScopeProvider>
60+
);
61+
}
62+
63+
function pager() {
64+
const Pagination = ComponentRegistry.get('pagination') as React.ComponentType<{ schema: unknown }>;
65+
return <Pagination schema={{ type: 'pagination', currentPage: 2, totalPages: 3 }} />;
66+
}
67+
68+
describe('components name their controls from the pack (objectui#12145)', () => {
69+
it('with no provider, each name is the en pack value', () => {
70+
render(
71+
<>
72+
<Spinner />
73+
{rowMenuTable()}
74+
{pager()}
75+
</>,
76+
);
77+
expect(screen.getByRole('status', { name: en.common.loading })).toBeInTheDocument();
78+
expect(screen.getByRole('button', { name: en.table.rowActions })).toBeInTheDocument();
79+
expect(screen.getByRole('link', { name: en.table.previousPage })).toBeInTheDocument();
80+
expect(screen.getByRole('link', { name: en.table.nextPage })).toBeInTheDocument();
81+
});
82+
83+
it('under zh, the spinner is named by the zh pack', async () => {
84+
expect(zh.common.loading).not.toBe(en.common.loading);
85+
render(inZh(<Spinner />));
86+
expect(await screen.findByRole('status', { name: zh.common.loading })).toBeInTheDocument();
87+
expect(screen.queryByRole('status', { name: en.common.loading })).toBeNull();
88+
});
89+
90+
it('under zh, the row menu trigger is named by the zh pack', async () => {
91+
expect(zh.table.rowActions).not.toBe(en.table.rowActions);
92+
render(inZh(rowMenuTable()));
93+
expect(await screen.findByRole('button', { name: zh.table.rowActions })).toBeInTheDocument();
94+
expect(screen.queryByRole('button', { name: en.table.rowActions })).toBeNull();
95+
});
96+
97+
it('under zh, the pagination links are named by the zh pack, not by the primitive defaults', async () => {
98+
expect(zh.table.previousPage).not.toBe(en.table.previousPage);
99+
expect(zh.table.nextPage).not.toBe(en.table.nextPage);
100+
render(inZh(pager()));
101+
expect(await screen.findByRole('link', { name: zh.table.previousPage })).toBeInTheDocument();
102+
expect(screen.getByRole('link', { name: zh.table.nextPage })).toBeInTheDocument();
103+
expect(screen.queryByRole('link', { name: 'Go to previous page' })).toBeNull();
104+
expect(screen.queryByRole('link', { name: 'Go to next page' })).toBeNull();
105+
});
106+
});

0 commit comments

Comments
 (0)