diff --git a/.changeset/12145-aria-label-i18n.md b/.changeset/12145-aria-label-i18n.md new file mode 100644 index 0000000000..49b7179042 --- /dev/null +++ b/.changeset/12145-aria-label-i18n.md @@ -0,0 +1,18 @@ +--- +'@object-ui/i18n': minor +'@object-ui/plugin-chatbot': minor +'@object-ui/components': patch +'@object-ui/fields': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-designer': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-list': patch +'@object-ui/console': patch +--- + +Screen readers hear the names of more controls in the viewer's language (objectui#12145). + +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. + +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. diff --git a/apps/console/src/pages/AppDocsIndex.tsx b/apps/console/src/pages/AppDocsIndex.tsx index 7ad73c045c..8f1164d795 100644 --- a/apps/console/src/pages/AppDocsIndex.tsx +++ b/apps/console/src/pages/AppDocsIndex.tsx @@ -9,6 +9,7 @@ import { useMemo } from 'react'; import { Link, Navigate, useParams } from 'react-router-dom'; import { BookOpen, Loader2 } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { DocShell } from './DocShell'; import { useBookData } from './use-book-data'; import { buildBookCards, pkgOfBook, type Book } from './book-nav'; @@ -26,6 +27,7 @@ import { buildBookCards, pkgOfBook, type Book } from './book-nav'; */ export default function AppDocsIndex() { const { appName } = useParams<{ appName?: string }>(); + const { t } = useObjectTranslation(); const { books, docs, state } = useBookData(); const myBooks = useMemo( @@ -37,7 +39,7 @@ export default function AppDocsIndex() { if (state === 'loading') { return (
- +
); } diff --git a/apps/console/src/pages/BookPage.tsx b/apps/console/src/pages/BookPage.tsx index 3941899b31..8a864653d5 100644 --- a/apps/console/src/pages/BookPage.tsx +++ b/apps/console/src/pages/BookPage.tsx @@ -9,6 +9,7 @@ import { useMemo } from 'react'; import { Navigate, useParams } from 'react-router-dom'; import { BookOpen, FileQuestion, Loader2, Lock } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { DocShell } from './DocShell'; import { useBookData } from './use-book-data'; import { resolveBookTree, bookSlug, firstDoc } from './book-nav'; @@ -43,6 +44,7 @@ export function DocRefusal({ name, message }: { name?: string; message?: string */ export default function BookPage() { const { slug, appName } = useParams<{ slug: string; appName?: string }>(); + const { t } = useObjectTranslation(); const { books, docs, state } = useBookData(); const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]); @@ -54,7 +56,7 @@ export default function BookPage() { if (state === 'loading') { return (
- +
); } diff --git a/apps/console/src/pages/DocPage.tsx b/apps/console/src/pages/DocPage.tsx index 27ac4e46f5..49dd12df2a 100644 --- a/apps/console/src/pages/DocPage.tsx +++ b/apps/console/src/pages/DocPage.tsx @@ -174,7 +174,7 @@ export default function DocPage() { if (state === 'loading') { return (
- +
); } diff --git a/apps/console/src/pages/DocsIndex.tsx b/apps/console/src/pages/DocsIndex.tsx index 40900c7b35..aec89be5c2 100644 --- a/apps/console/src/pages/DocsIndex.tsx +++ b/apps/console/src/pages/DocsIndex.tsx @@ -9,6 +9,7 @@ import { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { AlertCircle, BookOpen, Loader2 } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { DocShell } from './DocShell'; import { useBookData } from './use-book-data'; import { buildBookCards } from './book-nav'; @@ -23,6 +24,7 @@ import { buildBookCards } from './book-nav'; * reader lives at `/docs//`. */ export default function DocsIndex() { + const { t } = useObjectTranslation(); const { books, docs, state, error } = useBookData(); // Cards carry the book's url slug so links match the `/docs/:slug` route. @@ -31,7 +33,7 @@ export default function DocsIndex() { if (state === 'loading') { return (
- +
); } diff --git a/apps/console/src/pages/DocsSlug.tsx b/apps/console/src/pages/DocsSlug.tsx index 420116a423..cb207c789f 100644 --- a/apps/console/src/pages/DocsSlug.tsx +++ b/apps/console/src/pages/DocsSlug.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from 'react'; import { Navigate, useParams } from 'react-router-dom'; import { FileQuestion, Loader2 } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; import { useAdapter } from '@object-ui/app-shell'; import { DocShell } from './DocShell'; import BookPage, { DocRefusal } from './BookPage'; @@ -70,6 +71,7 @@ async function refusalFor( */ export default function DocsSlug() { const { slug, appName } = useParams<{ slug: string; appName?: string }>(); + const { t } = useObjectTranslation(); const { books, docs, state } = useBookData(); const adapter = useAdapter(); @@ -97,7 +99,7 @@ export default function DocsSlug() { if (state === 'loading' || (unanswered && !!adapter && answer?.slug !== slug)) { return (
- +
); } diff --git a/apps/console/src/pages/docsLoadingName-12145.test.tsx b/apps/console/src/pages/docsLoadingName-12145.test.tsx new file mode 100644 index 0000000000..42f220a453 --- /dev/null +++ b/apps/console/src/pages/docsLoadingName-12145.test.tsx @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** + * objectui#12145 — the docs portal's loading spinner is named from the pack. + * + * Five docs pages (`DocsIndex`, `AppDocsIndex`, `BookPage`, `DocsSlug`, + * `DocPage`) drew the same spinner with `aria-label="Loading documentation"`, + * so a screen reader under `zh` heard English. Each now reads + * `help.loadingDocumentation`. The portal index stands in for the five: same + * line, same key. + * + * The spinner is a lucide ``, and testing-library's role table gives an + * `` no implicit role, so it is found by its accessible name through + * `getByLabelText` — the name is what a screen reader announces. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; + +vi.mock('./use-book-data', () => ({ + useBookData: () => ({ books: [], docs: [], state: 'loading', error: null }), +})); + +import DocsIndex from './DocsIndex'; + +afterEach(cleanup); + +function renderIn(language: string) { + return render( + + + + + , + ); +} + +describe('the docs portal names its loading spinner from the pack (objectui#12145)', () => { + it('under en, the spinner is named by the en pack', async () => { + renderIn('en'); + expect(await screen.findByLabelText(en.help.loadingDocumentation)).toBeInTheDocument(); + }); + + it('under zh, the spinner is named by the zh pack, not by the English words', async () => { + // The pin is only a pin if the two packs disagree. + expect(zh.help.loadingDocumentation).not.toBe(en.help.loadingDocumentation); + renderIn('zh'); + expect(await screen.findByLabelText(zh.help.loadingDocumentation)).toBeInTheDocument(); + expect(screen.queryByLabelText(en.help.loadingDocumentation)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx b/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx index ade4830b8c..9a42245314 100644 --- a/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx +++ b/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx @@ -36,6 +36,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; import { RecordFormPage } from './RecordFormPage'; const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() })); @@ -85,6 +86,9 @@ vi.mock('../providers/AdapterProvider', () => ({ type Lang = 'en' | 'zh-CN'; +/** The breadcrumb landmark's name in each language under test, read from the pack. */ +const CRUMB_NAME: Record = { en: en.common.breadcrumb, 'zh-CN': zh.common.breadcrumb }; + /** The catalog as the console loads it: `zh-CN` translates both scalars. */ const BUNDLE: Record> = { en: {}, @@ -119,8 +123,11 @@ async function pageText(lang: Lang, mode: 'create' | 'edit', objectName: string) , ); const title = (await screen.findByTestId('record-form-page-title')).textContent ?? ''; - // The breadcrumb's one link is the way to the object's list. - const crumb = screen.getByRole('navigation', { name: 'Breadcrumb' }); + // The breadcrumb's one link is the way to the object's list. The landmark + // is found by the name the language under test's PACK gives it + // (`common.breadcrumb`, objectui#12145): under zh-CN that is the zh pack's + // word, so this lookup is also the package's zh role-and-name pin. + const crumb = screen.getByRole('navigation', { name: CRUMB_NAME[lang] }); const link = crumb.querySelector('a'); expect(link?.getAttribute('href')).toBe(`/apps/demo/${objectName}`); fireEvent.click(screen.getByTestId('fire-success')); diff --git a/packages/app-shell/src/views/RecordFormPage.tsx b/packages/app-shell/src/views/RecordFormPage.tsx index 7bc9876aa5..1363f4d78f 100644 --- a/packages/app-shell/src/views/RecordFormPage.tsx +++ b/packages/app-shell/src/views/RecordFormPage.tsx @@ -329,7 +329,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { > -
diff --git a/packages/components/src/renderers/basic/pagination.tsx b/packages/components/src/renderers/basic/pagination.tsx index 67865a94ca..31d8c165c9 100644 --- a/packages/components/src/renderers/basic/pagination.tsx +++ b/packages/components/src/renderers/basic/pagination.tsx @@ -9,10 +9,28 @@ import { ComponentRegistry } from '@object-ui/core'; import type { PaginationSchema } from '@object-ui/types'; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '../../ui/pagination'; +import { createSafeTranslation } from '@object-ui/i18n'; import React from 'react'; +/** + * objectui#12145 — the previous / next links' accessible names. The Shadcn + * primitives hard-code English ones (`ui/pagination.tsx` is a No-Touch zone, + * AGENTS.md commandment #7), and spread `props` after them, so this call site + * names them instead. The keys are the data table pager's (objectui#12124), the + * same two names; the rows are the `en` pack's values, served when no + * `I18nProvider` is mounted. + */ +const usePaginationTranslation = createSafeTranslation( + { + 'table.previousPage': 'Previous page', + 'table.nextPage': 'Next page', + }, + 'table.previousPage', +); + ComponentRegistry.register('pagination', ({ schema, ...props }: { schema: PaginationSchema; [key: string]: any }) => { + const { t } = usePaginationTranslation(); const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, @@ -62,6 +80,7 @@ ComponentRegistry.register('pagination', handlePageChange(currentPage - 1, e)} className={currentPage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} aria-disabled={currentPage <= 1} @@ -86,6 +105,7 @@ ComponentRegistry.register('pagination', handlePageChange(currentPage + 1, e)} className={currentPage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} aria-disabled={currentPage >= totalPages} diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 1d858967ec..41cddf6648 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -229,6 +229,8 @@ const TABLE_DEFAULT_TRANSLATIONS: Record = { // objectui#11690 — the selection checkboxes' accessible names. 'table.selectAllRows': 'Select all rows', 'table.selectRow': 'Select row', + // objectui#12145 — the row menu trigger's accessible name. + 'table.rowActions': 'Row actions', 'table.edit': 'Edit', 'table.delete': 'Delete', 'common.actions': 'Actions', @@ -676,7 +678,7 @@ const DataTableRowActionsMenu: React.FC<{ size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()} - aria-label="Row actions" + aria-label={t('table.rowActions')} > diff --git a/packages/fields/src/__tests__/ariaLabelNames.zh-12145.test.tsx b/packages/fields/src/__tests__/ariaLabelNames.zh-12145.test.tsx new file mode 100644 index 0000000000..d4b57f500b --- /dev/null +++ b/packages/fields/src/__tests__/ariaLabelNames.zh-12145.test.tsx @@ -0,0 +1,49 @@ +/** + * 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. + */ + +/** + * objectui#12145 — `@object-ui/fields`' accessible names are read from the + * pack through `useFieldTranslation`, so under `zh` a screen reader hears the + * zh pack's words. The percent widget's slider stands in for the package: it + * was `aria-label="Percentage"`, and is now `fields.percent.slider`. The crop + * dialog's canvas (`fields.image.cropPreview`) and the record picker's grid + * (`console.commandPalette.records`) read the same hook. + * + * The name is read against the PACK, never against a literal. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { FieldMetadata } from '@object-ui/types'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; +import { PercentField } from '../widgets/PercentField'; + +afterEach(cleanup); + +const field = { name: 'rate', label: 'rate', type: 'percent' } as unknown as FieldMetadata; +const percent = () => {}} field={field} />; + +describe('the percent slider is named from the pack (objectui#12145)', () => { + it('with no provider, it carries the en pack value', () => { + render(percent()); + expect(screen.getByRole('slider', { name: en.fields.percent.slider })).toBeInTheDocument(); + }); + + it('under zh, it is named by the zh pack, not by the English word', async () => { + expect(zh.fields.percent.slider).not.toBe(en.fields.percent.slider); + render( + + {percent()} + , + ); + expect(await screen.findByRole('slider', { name: zh.fields.percent.slider })).toBeInTheDocument(); + expect(screen.queryByRole('slider', { name: en.fields.percent.slider })).toBeNull(); + }); +}); diff --git a/packages/fields/src/widgets/ImageCropperDialog.tsx b/packages/fields/src/widgets/ImageCropperDialog.tsx index f9fd3340c8..36348fb122 100644 --- a/packages/fields/src/widgets/ImageCropperDialog.tsx +++ b/packages/fields/src/widgets/ImageCropperDialog.tsx @@ -20,6 +20,7 @@ import { DialogTitle, } from '@object-ui/components'; import { RotateCw, Crop as CropIcon } from 'lucide-react'; +import { useFieldTranslation } from './useFieldTranslation.js'; export interface ImageCropperDialogProps { open: boolean; @@ -49,6 +50,7 @@ export const ImageCropperDialog: React.FC = ({ outputName = 'cropped.png', onConfirm, }) => { + const { t } = useFieldTranslation(); const canvasRef = useRef(null); const imgRef = useRef(null); const [rotation, setRotation] = useState(0); // 0, 90, 180, 270 @@ -194,7 +196,7 @@ export const ImageCropperDialog: React.FC = ({ ref={canvasRef} data-testid="image-cropper-canvas" role="img" - aria-label="Image crop preview" + aria-label={t('fields.image.cropPreview')} className="border rounded-md cursor-move touch-none max-w-full" onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} diff --git a/packages/fields/src/widgets/PercentField.tsx b/packages/fields/src/widgets/PercentField.tsx index 3f4959baba..c08553c97d 100644 --- a/packages/fields/src/widgets/PercentField.tsx +++ b/packages/fields/src/widgets/PercentField.tsx @@ -6,6 +6,7 @@ import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberBadInput.js'; import { formatPercentPoints } from './number-format.js'; +import { useFieldTranslation } from './useFieldTranslation.js'; /** * The stored fraction a typed percentage-point value becomes — computed by @@ -128,6 +129,7 @@ export function PercentField({ value, onChange, field, readonly, error, classNam // Before the readonly return below: hooks are unconditional (objectui#6780). const { refusal, readBadInput } = useBadInputRefusal('12.5'); const locale = useDisplayLocale(); + const { t } = useFieldTranslation(); // The storage convention, read through the spec's `percentScaleOf` BY // REFERENCE (objectui#11475): a field declaring a `max` above 1 (e.g. @@ -291,7 +293,7 @@ export function PercentField({ value, onChange, field, readonly, error, classNam step={sliderStep} disabled={readonly || props.disabled} className="w-full" - aria-label="Percentage" + aria-label={t('fields.percent.slider')} data-testid="percent-slider" />
diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 1f86d48cd4..8c9a285fa0 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -1391,7 +1391,7 @@ export function RecordPickerDialog({ tabIndex={0} onKeyDown={handleTableKeyDown} role="grid" - aria-label="Records" + aria-label={t('console.commandPalette.records')} > {/* Loading overlay for subsequent fetches (page/sort/filter) */} {loading && ( diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 2ca676b43c..58ca559080 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -59,6 +59,13 @@ const FIELD_DEFAULTS: Record = { // renderer so both cannot drift apart in a locale. 'fields.options.empty': 'No options available', 'fields.options.selectFirst': 'Select {{fields}} first', + // objectui#12145 — three widgets' accessible names: the crop dialog's + // preview canvas, the percent slider, and the record picker's grid. The + // grid borrows the command palette's `Records` key rather than adding a + // second key for the same name. Same values as the `en` pack. + 'fields.image.cropPreview': 'Image crop preview', + 'fields.percent.slider': 'Percentage', + 'console.commandPalette.records': 'Records', // objectui#4026 — the separator between the controlling-field names that // fill `{{fields}}` of the two gate sentences above/below (`lookup. // selectFirst`, `fields.options.selectFirst`). It is a LOCALE property, not diff --git a/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts b/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts new file mode 100644 index 0000000000..dc2317d2c8 --- /dev/null +++ b/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts @@ -0,0 +1,395 @@ +/** + * 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. + */ + +/** + * No shipped source may write an accessible name as a LITERAL `aria-label` + * unless the site is declared below (objectui#12145). + * + * ## The defect, and why it is a census + * + * An `aria-label="Row actions"` is the name a screen reader announces. Written + * as a literal it never passes through the locale packs, so a reader under + * `zh` hears English. Single-site cards closed one position each (objectui#4474, + * #4645, #10104 and more) and the shape kept coming back, because nothing + * re-derived it. objectui#12145 is the family's closing card: its sites now read + * their names through their package's translation hook, and this census is the + * instrument that keeps a new one from landing. Its triage ruled it a test in + * the ordinary suite, ⛔ not a new CI gate, and a family of its own: it shares + * nothing with the icon-only-button census objectui#12143 asks for. + * + * ## The population, stated as a rule + * + * Every source file under a `src/` tree of a `packages/*` or `apps/*` workspace, + * minus test and measurement tooling (the same `TOOLING` shape + * `machineLocaleCensus-9909.test.ts` reads). Not only the packages that depend + * on `@object-ui/i18n`: a literal name is untranslatable wherever it is written, + * and a backlog package with no translation hook to reach for yet (one that + * does not depend on `@object-ui/i18n`) is a fact about the fix, not a reason + * to stop counting. + * + * ⚠️ The card's own re-check command, `git grep … -- 'packages/*\/src/**\/*.tsx'`, + * reached only files in a SUBDIRECTORY of `src/`: without git's `:(glob)` magic + * that pathspec needs a `/` after `src/`, so no file directly under `src/` was + * ever read. It enumerated 31 sites; the same regex with `:(glob)` finds several + * times as many. This census walks the tree instead, so it cannot inherit that + * blind spot. The sites the card did not enumerate are the BACKLOG below. + * + * ## The four shapes it judges + * + * - `aria-label="…"` / `aria-label='…'`: a JSX attribute string; + * - `aria-label={'…'}` / `aria-label={"…"}`: the same string behind braces, + * which the card's grep could not see; + * - `` aria-label={`…`} `` with no `${`: a template that interpolates nothing; + * - `'aria-label': '…'`: the object-key spelling of the same prop. + * + * A value is judged only when it carries a letter, read with `\p{L}` so a name + * in any script counts (AGENTS.md's i18n forensics rule: an ASCII class would + * call a Cyrillic or CJK literal clean). + * + * ## ⛔ What this instrument CANNOT see — stated, not assumed + * + * - A template that interpolates (`` aria-label={`Remove ${name}`} ``): the + * English words are real, but the value is an expression, and judging it + * belongs to a card that decides how such a name is keyed. + * - A literal inside a larger expression (`labels?.x ?? 'Submit'`, + * `cond ? 'A' : 'B'`, a default parameter, a variable): most of those are a + * `t(key, { defaultValue })` doing its job, and a source scan cannot tell + * the two apart. + * - Every other carrier of an accessible name: `title=`, `placeholder=`, + * `alt=`, an `sr-only` span, a prop that a component forwards as + * `aria-label` under another name. + * - A span the comment masker over-masks: JSX TEXT holding `/*` or `//` reads + * to it as a comment opener (the objectui#7882 shape, which + * `scripts/check-comment-mask-corpus.mjs` reports). Masking is still + * load-bearing: the card's enumeration counted a comment that quotes a past + * repair as a live site. + */ + +import { describe, it, expect } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped — the call site stays checked. */ +const mask: (source: string) => string = maskComments; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// packages/i18n/src/__tests__ -> repository root +const repoRoot = path.resolve(here, '../../../..'); + +/* -------------------------------------------------------------------------- */ +/* The population */ +/* -------------------------------------------------------------------------- */ + +const WORKSPACE_PARENTS = ['packages', 'apps'] as const; + +const workspaceDirs: string[] = WORKSPACE_PARENTS.flatMap((parent) => + readdirSync(path.join(repoRoot, parent), { withFileTypes: true }) + .filter((e) => e.isDirectory() && existsSync(path.join(repoRoot, parent, e.name, 'package.json'))) + .map((e) => `${parent}/${e.name}`), +); +const population = workspaceDirs.filter((dir) => existsSync(path.join(repoRoot, dir, 'src'))); + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', 'out', '.turbo', '.next']); +/** Test and measurement tooling never ships; the same shape `machineLocaleCensus-9909.test.ts` reads. */ +const TOOLING = /(^|\/)(__tests__|__mocks__|__benchmarks__|e2e)\/|\.(test|spec|bench|stories)\.[cm]?[jt]sx?$/; + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(full, out); + } else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + out.push(full); + } + } + return out; +} + +const sources: Array<{ pkg: string; rel: string; masked: string }> = population.flatMap((pkg) => + walk(path.join(repoRoot, pkg, 'src')) + .map((f) => path.relative(repoRoot, f).split(path.sep).join('/')) + .filter((rel) => !TOOLING.test(rel)) + .map((rel) => ({ pkg, rel, masked: mask(readFileSync(path.join(repoRoot, rel), 'utf8')) })), +); + +/* -------------------------------------------------------------------------- */ +/* The matcher */ +/* -------------------------------------------------------------------------- */ + +/** + * One alternation per shape; exactly one capture group is set per match. The + * template arm refuses a `$`, so an interpolating template never matches. + */ +const SITE = new RegExp( + [ + String.raw`aria-label\s*=\s*"([^"]*)"`, + String.raw`aria-label\s*=\s*'([^']*)'`, + String.raw`aria-label\s*=\s*\{\s*"((?:[^"\\\n]|\\.)*)"\s*\}`, + String.raw`aria-label\s*=\s*\{\s*'((?:[^'\\\n]|\\.)*)'\s*\}`, + String.raw`aria-label\s*=\s*\{\s*` + '`([^`$]*)`' + String.raw`\s*\}`, + String.raw`["']aria-label["']\s*:\s*"((?:[^"\\\n]|\\.)*)"`, + String.raw`["']aria-label["']\s*:\s*'((?:[^'\\\n]|\\.)*)'`, + ].join('|'), + 'g', +); +const HAS_LETTER = /\p{L}/u; + +interface Site { + file: string; + line: number; + /** The literal accessible name, as written. */ + name: string; +} + +function scan(file: string, masked: string): Site[] { + const found: Site[] = []; + SITE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = SITE.exec(masked)) !== null) { + const name = m.slice(1).find((g) => g !== undefined) ?? ''; + if (!HAS_LETTER.test(name)) continue; + found.push({ file, line: masked.slice(0, m.index).split('\n').length, name }); + } + return found; +} + +const sites: Site[] = sources.flatMap((s) => scan(s.rel, s.masked)); + +/* -------------------------------------------------------------------------- */ +/* The declared sites — file · names · verdict · reason */ +/* -------------------------------------------------------------------------- */ + +interface Declared { + file: string; + /** Every literal name the file may carry, once per occurrence. Cited by content, never by line. */ + names: string[]; + verdict: 'exempt' | 'backlog'; + reason: string; +} + +/** + * Sites the card enumerated that stay literal, each with why. These are the + * "stated list" objectui#12145's acceptance asks the PR to give. + */ +const EXEMPT: Declared[] = [ + { + file: 'packages/components/src/debug/DebugPanel.tsx', + names: ['Developer Debug Panel', 'Close debug panel'], + verdict: 'exempt', + reason: + 'a developer tool, not product UI: no objectui surface mounts it (a host opts in behind `?__debug` or ' + + 'Ctrl+Shift+D), and its whole chrome — heading, tab names, empty states — is English developer copy, so ' + + 'translating two names would leave the panel half in one language.', + }, + { + file: 'packages/components/src/ui/pagination.tsx', + names: ['Go to previous page', 'Go to next page'], + verdict: 'exempt', + reason: + 'the No-Touch Shadcn primitive (AGENTS.md commandment #7) spreads `props` after these defaults, and its ' + + 'one shipped call site, the `pagination` renderer, passes `table.previousPage` / `table.nextPage`.', + }, + { + file: 'packages/components/src/ui/sidebar.tsx', + names: ['Toggle Sidebar'], + verdict: 'exempt', + reason: + "`SidebarRail`'s default in the No-Touch Shadcn primitive. No objectui surface renders the rail; the only " + + "writer is the CLI's app generator, whose generated app mounts no `I18nProvider`. A host names it through " + + '`aria-label` / `title`, which spread last (`common.toggleSidebar` is the key).', + }, + { + file: 'packages/plugin-chatbot/src/elements/message.tsx', + names: ['Previous branch', 'Next branch', 'Remove attachment', 'Remove attachment'], + verdict: 'exempt', + reason: + 'vendored from vercel/ai-elements (the file header says to wrap, not edit), and no objectui surface renders ' + + '`MessageBranchPrevious`, `MessageBranchNext` or `MessageAttachment`: they are only re-exported as ' + + '`AIElements`. The branch buttons spread `props` last, so a host can name them.', + }, + { + file: 'packages/plugin-chatbot/src/elements/prompt-input.tsx', + names: ['Submit'], + verdict: 'exempt', + reason: + "the vendored `PromptInputSubmit`'s default, which spreads `props` after it; its one shipped call site, " + + "`ChatbotEnhanced`, passes `console.ai.submit` / `console.ai.stopResponse` read from the pack.", + }, +]; + +const BACKLOG_REASON = + "outside objectui#12145's enumeration: the card's re-check pathspec never reached a file directly under " + + '`src/`, and these were found by this census. Read each through its package translation hook, then delete ' + + 'the name here in the same commit.'; + +/** + * Sites still literal that the card did not enumerate. Frozen: a new one fails, + * and a translated one must leave this list in the same commit. + */ +const BACKLOG: Declared[] = ( + [ + ['packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx', ['JSON source']], + ['packages/app-shell/src/views/metadata-admin/QuickFind.tsx', ['locked']], + ['packages/auth/src/UserMenu.tsx', ['Profile', 'Settings', 'Sign out']], + ['packages/components/src/custom/config-panel-renderer.tsx', ['breadcrumb']], + ['packages/components/src/ui/breadcrumb.tsx', ['breadcrumb']], + ['packages/components/src/ui/pagination.tsx', ['pagination']], + ['packages/layout/src/AppSchemaRenderer.tsx', ['Mobile navigation', 'Search navigation']], + ['packages/layout/src/PageHeader.tsx', ['Back to list']], + ['packages/plugin-chatbot/src/FloatingChatbotPanel.tsx', ['Close chat']], + ['packages/plugin-designer/src/AppCreationWizard.tsx', ['Move up', 'Move down', 'Remove']], + ['packages/plugin-designer/src/DashboardEditor.tsx', ['Move up', 'Move down', 'Remove widget', 'Close']], + [ + 'packages/plugin-designer/src/DataModelDesigner.tsx', + [ + 'Designer toolbar', 'Undo', 'Redo', 'Zoom Out', 'Zoom In', 'Reset Zoom', 'Copy', 'Paste', 'Auto Layout', + 'Collaboration active', 'Add Entity', 'Add Relationship', 'Canvas', + ], + ], + ['packages/plugin-designer/src/EditorModeToggle.tsx', ['Editor mode']], + [ + 'packages/plugin-designer/src/PageDesigner.tsx', + [ + 'Hidden', 'Locked', 'Component palette', 'Designer toolbar', 'Undo', 'Redo', 'Copy', 'Paste', + 'Delete selected', 'Preview', 'Toggle minimap', 'Zoom out', 'Zoom in', 'Reset zoom', 'Design canvas', + 'Component tree', 'Hidden', 'Locked', + ], + ], + [ + 'packages/plugin-designer/src/ProcessDesigner.tsx', + [ + 'Process toolbar', 'Process name', 'Process version', 'Collaboration active', 'Undo', 'Redo', 'Copy', + 'Paste', 'Connect nodes', 'Auto layout', 'Zoom in', 'Zoom level', 'Zoom out', 'Reset zoom', + 'Add Start Event', 'Add User Task', 'Add Gateway', 'Add End Event', 'Process canvas', 'Delete node', + 'Property panel', + ], + ], + ['packages/plugin-detail/src/ThreadedReplies.tsx', ['Send reply']], + ['packages/plugin-grid/src/FormulaBar.tsx', ['Confirm', 'Cancel']], + ['packages/plugin-grid/src/InlineEditing.tsx', ['Save', 'Cancel']], + ['packages/plugin-kanban/src/CardTemplates.tsx', ['Card templates']], + ['packages/plugin-kanban/src/InlineQuickAdd.tsx', ['Quick add card']], + ['packages/plugin-kanban/src/KanbanImpl.tsx', ['Kanban board with swimlanes', 'Kanban board']], + ['packages/plugin-list/src/ListView.tsx', ['Bulk actions']], + [ + 'packages/plugin-map/src/ObjectMap.tsx', + ['Locations', 'Show my location', 'Your location', 'Location details', 'Close details'], + ], + ] as Array<[string, string[]]> +).map(([file, names]) => ({ file, names, verdict: 'backlog' as const, reason: BACKLOG_REASON })); + +const DECLARED: Declared[] = [...EXEMPT, ...BACKLOG]; + +/** `file · name` -> how many times DECLARED allows it. */ +function tally(entries: Array<{ file: string; name: string }>): Map { + const out = new Map(); + for (const { file, name } of entries) { + const k = `${file} · ${name}`; + out.set(k, (out.get(k) ?? 0) + 1); + } + return out; +} + +const declaredTally = tally(DECLARED.flatMap((d) => d.names.map((name) => ({ file: d.file, name })))); +const foundTally = tally(sites); + +/* -------------------------------------------------------------------------- */ + +describe('no shipped source names an element with a literal aria-label unless it is declared (objectui#12145)', () => { + /** + * ⭐ The width guards. The assertion that matters is "nothing undeclared", + * which a scan that read NOTHING satisfies — so a narrowing must be loud. + */ + it('the population is every packages/* and apps/* src tree, and it is wide', () => { + expect(workspaceDirs.length, 'workspace directories under packages/ and apps/').toBeGreaterThan(30); + expect(population.length, 'workspaces with a src/ tree').toBeGreaterThan(30); + for (const pkg of ['apps/console', 'packages/components', 'packages/plugin-designer', 'packages/auth']) { + expect(population, `${pkg} is scanned`).toContain(pkg); + } + expect(sources.length).toBeGreaterThan(1000); + expect(sources.filter((s) => s.rel.endsWith('.tsx')).length).toBeGreaterThan(500); + // The card's pathspec read none of these: a file directly under a `src/`. + const directlyUnderSrc = sources.filter((s) => /^(packages|apps)\/[^/]+\/src\/[^/]+$/.test(s.rel)); + expect(directlyUnderSrc.length, 'files directly under a src/ root are in the population').toBeGreaterThan(100); + }); + + /** + * ⭐ The lit control. The site the card's pathspec could not reach (a file + * directly under `src/`) must be SEEN, or the census has the card's blind spot. + */ + it('a file directly under src/ is read, and a declared site in it is found', () => { + const control = sites.filter((s) => s.file === 'packages/plugin-designer/src/PageDesigner.tsx'); + expect(control.map((s) => s.name)).toContain('Component palette'); + expect(sites.length).toBeGreaterThan(50); + }); + + it('every literal accessible name is declared, exactly as many times as it is written', () => { + const keys = new Set([...declaredTally.keys(), ...foundTally.keys()]); + const wrong = [...keys] + .filter((k) => (declaredTally.get(k) ?? 0) !== (foundTally.get(k) ?? 0)) + .sort() + .map((k) => { + const where = sites + .filter((s) => `${s.file} · ${s.name}` === k) + .map((s) => `:${s.line}`) + .join(' '); + return `${k} — declared ${declaredTally.get(k) ?? 0}, found ${foundTally.get(k) ?? 0} ${where}`.trim(); + }); + expect( + wrong, + 'a NEW literal: read the name through the package translation hook (`t(key)`, a key in all ten packs) — or ' + + 'declare it in EXEMPT with its reason. A REMOVED one: delete its name here in the same commit.', + ).toEqual([]); + }); + + it('every exemption says why, and no file is both exempt and backlog for one name', () => { + for (const e of EXEMPT) expect(e.reason.length, `${e.file} carries a reason`).toBeGreaterThan(80); + const exempt = new Set(EXEMPT.flatMap((e) => e.names.map((n) => `${e.file} · ${n}`))); + const both = BACKLOG.flatMap((b) => b.names.map((n) => `${b.file} · ${n}`)).filter((k) => exempt.has(k)); + expect(both).toEqual([]); + }); + + /** + * ⭐ The matcher's own control: each shape it judges is counted, and each it + * does not judge is not. Without it every `toEqual([])` above passes on a + * regex that matches nothing. + */ + it('the matcher counts the four literal shapes and nothing else', () => { + const fixture = [ + '', + "", + "", + '', + '', + "const props = { 'aria-label': 'Object key' };", + '', + '', + // not judged: an expression, an interpolating template, a value without a letter, a comment + "", + '', + '', + '', + '// ', + '/* */', + ].join('\n'); + expect(scan('fixture', mask(fixture)).map((s) => s.name)).toEqual([ + 'Double quoted', + 'Single quoted', + 'Braced single', + 'Braced double', + 'Plain template', + 'Object key', + 'Заголовок', + '名前', + ]); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 6ae3fa6aa9..eadfcc7caf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -163,6 +163,7 @@ const ar = { itemCount_many: "{{count}} عنصرًا", itemCount_other: "{{count}} عنصر", toggleSidebar: "تبديل الشريط الجانبي", + breadcrumb: "مسار التنقل", package: "الحزمة", done: "تم", editInStudio: "التعديل في Studio", @@ -297,10 +298,14 @@ const ar = { remove: "إزالة الصورة {{index}}", enlarge: "تكبير {{name}}", preview: "معاينة الصورة", + cropPreview: "معاينة قص الصورة", previous: "الصورة السابقة", next: "الصورة التالية", counter: "{{current}} / {{total}}", }, + percent: { + slider: "النسبة المئوية", + }, relativeDate: { overdue: "متأخر {{count}} يوم", overdue_zero: "متأخر {{count}} يوم", @@ -437,6 +442,7 @@ const ar = { selectAllRows: "تحديد جميع الصفوف", expandRow: "توسيع الصف", collapseRow: "طي الصف", + rowActions: "إجراءات الصف", hideColumn: "إخفاء العمود", freezeColumn: "تجميد العمود", unfreezeColumn: "إلغاء تجميد العمود", @@ -490,6 +496,7 @@ const ar = { bulkAllOnPage_other: "تم تحديد جميع العناصر ({{count}}) في هذه الصفحة.", bulkSelectAllMatching: "تحديد كل النتائج المطابقة ({{count}})", bulkAllMatchingSelected: "تم تحديد جميع السجلات المطابقة ({{count}}).", + bulkActions: "إجراءات جماعية", bulk: { confirmDefault: "سيُطبَّق هذا على {{count}} من السجلات.", confirmDefault_zero: "سيُطبَّق هذا على {{count}} من السجلات.", @@ -1155,6 +1162,8 @@ const ar = { designer: { undo: "تراجع", redo: "إعادة", + canvasMinimap: "الخريطة المصغرة للوحة", + versionHistory: "سجل الإصدارات", }, detail: { editInlineHint: "انقر نقرًا مزدوجًا للتحرير", @@ -2737,6 +2746,7 @@ const ar = { appDocs: 'وثائق هذا التطبيق', allDocs: 'كل الوثائق', onlineDocs: 'الوثائق عبر الإنترنت', + loadingDocumentation: 'جارٍ تحميل الوثائق', }, sidebar: { packageManagement: "إدارة الحزم", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5b867452d6..927270460d 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -127,6 +127,7 @@ const de = { itemCount_one: "{{count}} Element", itemCount_other: "{{count}} Elemente", toggleSidebar: "Seitenleiste umschalten", + breadcrumb: "Brotkrümelnavigation", package: "Paket", done: "Fertig", editInStudio: "Im Studio bearbeiten", @@ -261,10 +262,14 @@ const de = { remove: "Bild {{index}} entfernen", enlarge: "{{name}} vergrößern", preview: "Bildvorschau", + cropPreview: "Vorschau des Bildzuschnitts", previous: "Vorheriges Bild", next: "Nächstes Bild", counter: "{{current}} / {{total}}", }, + percent: { + slider: "Prozentsatz", + }, relativeDate: { overdue: "{{count}} T. überfällig", overdue_one: "{{count}} T. überfällig", @@ -397,6 +402,7 @@ const de = { selectAllRows: "Alle Zeilen auswählen", expandRow: "Zeile erweitern", collapseRow: "Zeile reduzieren", + rowActions: "Zeilenaktionen", hideColumn: "Spalte ausblenden", freezeColumn: "Spalte fixieren", unfreezeColumn: "Spalte lösen", @@ -434,6 +440,7 @@ const de = { bulkAllOnPage_other: "Alle {{count}} auf dieser Seite sind ausgewählt.", bulkSelectAllMatching: "Alle {{count}} Treffer auswählen", bulkAllMatchingSelected: "Alle {{count}} übereinstimmenden Datensätze sind ausgewählt.", + bulkActions: "Massenaktionen", bulk: { confirmDefault: "Dies wird auf {{count}} Datensätze angewendet.", confirmDefault_one: "Dies wird auf {{count}} Datensatz angewendet.", @@ -1032,6 +1039,8 @@ const de = { designer: { undo: "Rückgängig", redo: "Wiederholen", + canvasMinimap: "Minikarte der Arbeitsfläche", + versionHistory: "Versionsverlauf", }, detail: { editInlineHint: "Zum Bearbeiten doppelklicken", @@ -2533,6 +2542,7 @@ const de = { appDocs: 'Dokumentation dieser App', allDocs: 'Gesamte Dokumentation', onlineDocs: 'Online-Dokumentation', + loadingDocumentation: 'Dokumentation wird geladen', }, sidebar: { packageManagement: "Paketverwaltung", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4370eb5bb0..d167fa198a 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -153,6 +153,7 @@ const en = { itemCount_one: '{{count}} item', itemCount_other: '{{count}} items', toggleSidebar: 'Toggle sidebar', + breadcrumb: 'Breadcrumb', package: 'Package', done: 'Done', editInStudio: 'Edit in studio', @@ -401,10 +402,14 @@ const en = { remove: 'Remove image {{index}}', enlarge: 'Enlarge {{name}}', preview: 'Image preview', + cropPreview: 'Image crop preview', previous: 'Previous image', next: 'Next image', counter: '{{current}} / {{total}}', }, + percent: { + slider: 'Percentage', + }, richText: { format: 'Format: {{format}}', basicEditorHint: 'Rich text editor (basic)', @@ -625,6 +630,7 @@ const en = { selectAllRows: 'Select all rows', expandRow: 'Expand row', collapseRow: 'Collapse row', + rowActions: 'Row actions', hideColumn: 'Hide column', freezeColumn: 'Freeze column', unfreezeColumn: 'Unfreeze column', @@ -673,6 +679,7 @@ const en = { bulkAllOnPage_other: 'All {{count}} on this page are selected.', bulkSelectAllMatching: 'Select all {{count}} matching', bulkAllMatchingSelected: 'All {{count}} matching records are selected.', + bulkActions: 'Bulk actions', rowHeight: 'Row height: {{mode}}', empty: 'Empty', yes: 'Yes', @@ -1627,6 +1634,8 @@ const en = { designer: { undo: 'Undo', redo: 'Redo', + canvasMinimap: 'Canvas minimap', + versionHistory: 'Version history', }, dashboard: { addWidget: 'Add widget', @@ -3036,6 +3045,7 @@ const en = { allDocs: 'All documentation', keyboardShortcuts: 'Keyboard shortcuts', onlineDocs: 'Online documentation', + loadingDocumentation: 'Loading documentation', }, sidebar: { settings: 'Settings', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 0953746d83..87fa20a67b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -132,6 +132,7 @@ const es = { itemCount_many: "{{count}} de elementos", itemCount_other: "{{count}} elementos", toggleSidebar: "Alternar barra lateral", + breadcrumb: "Ruta de navegación", package: "Paquete", done: "Listo", editInStudio: "Editar en Studio", @@ -271,10 +272,14 @@ const es = { remove: "Eliminar la imagen {{index}}", enlarge: "Ampliar {{name}}", preview: "Vista previa de la imagen", + cropPreview: "Vista previa del recorte de la imagen", previous: "Imagen anterior", next: "Imagen siguiente", counter: "{{current}} / {{total}}", }, + percent: { + slider: "Porcentaje", + }, relativeDate: { overdue: "Atrasado {{count}} d", overdue_one: "Atrasado {{count}} d", @@ -408,6 +413,7 @@ const es = { selectAllRows: "Seleccionar todas las filas", expandRow: "Expandir fila", collapseRow: "Contraer fila", + rowActions: "Acciones de fila", hideColumn: "Ocultar columna", freezeColumn: "Fijar columna", unfreezeColumn: "Desfijar columna", @@ -449,6 +455,7 @@ const es = { bulkAllOnPage_other: "Están seleccionados los {{count}} de esta página.", bulkSelectAllMatching: "Seleccionar las {{count}} coincidencias", bulkAllMatchingSelected: "Están seleccionados los {{count}} registros coincidentes.", + bulkActions: "Acciones masivas", bulk: { confirmDefault: "Esto se aplicará a {{count}} registro(s).", confirmDefault_one: "Esto se aplicará a {{count}} registro.", @@ -1063,6 +1070,8 @@ const es = { designer: { undo: "Deshacer", redo: "Rehacer", + canvasMinimap: "Minimapa del lienzo", + versionHistory: "Historial de versiones", }, detail: { editInlineHint: "Haga doble clic para editar", @@ -2583,6 +2592,7 @@ const es = { appDocs: 'Documentación de esta app', allDocs: 'Toda la documentación', onlineDocs: 'Documentación en línea', + loadingDocumentation: 'Cargando documentación', }, sidebar: { packageManagement: "Gestión de paquetes", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3b0242723e..023b3f1f38 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -133,6 +133,7 @@ const fr = { itemCount_many: "{{count}} d'éléments", itemCount_other: "{{count}} éléments", toggleSidebar: "Basculer la barre latérale", + breadcrumb: "Fil d'Ariane", package: "Package", done: "Terminé", editInStudio: "Modifier dans Studio", @@ -267,10 +268,14 @@ const fr = { remove: "Supprimer l'image {{index}}", enlarge: "Agrandir {{name}}", preview: "Aperçu de l'image", + cropPreview: "Aperçu du recadrage de l'image", previous: "Image précédente", next: "Image suivante", counter: "{{current}} / {{total}}", }, + percent: { + slider: "Pourcentage", + }, relativeDate: { overdue: "En retard de {{count}} j", overdue_one: "En retard de {{count}} j", @@ -404,6 +409,7 @@ const fr = { selectAllRows: "Sélectionner toutes les lignes", expandRow: "Développer la ligne", collapseRow: "Réduire la ligne", + rowActions: "Actions de la ligne", hideColumn: "Masquer la colonne", freezeColumn: "Figer la colonne", unfreezeColumn: "Libérer la colonne", @@ -445,6 +451,7 @@ const fr = { bulkAllOnPage_other: "Les {{count}} éléments de cette page sont sélectionnés.", bulkSelectAllMatching: "Sélectionner les {{count}} correspondances", bulkAllMatchingSelected: "Les {{count}} enregistrements correspondants sont tous sélectionnés.", + bulkActions: "Actions groupées", bulk: { confirmDefault: "Ceci s'appliquera à {{count}} enregistrement(s).", confirmDefault_one: "Ceci s'appliquera à {{count}} enregistrement.", @@ -1059,6 +1066,8 @@ const fr = { designer: { undo: "Annuler", redo: "Rétablir", + canvasMinimap: "Mini-carte du canevas", + versionHistory: "Historique des versions", }, detail: { editInlineHint: "Double-cliquez pour modifier", @@ -2580,6 +2589,7 @@ const fr = { appDocs: 'Documentation de cette app', allDocs: 'Toute la documentation', onlineDocs: 'Documentation en ligne', + loadingDocumentation: 'Chargement de la documentation', }, sidebar: { packageManagement: "Gestion des packages", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f6c1ab4cf2..d299a819bc 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -127,6 +127,7 @@ const ja = { itemCount_one: "{{count}} 件", itemCount_other: "{{count}} 件", toggleSidebar: "サイドバーを切り替え", + breadcrumb: "パンくずリスト", package: "パッケージ", done: "完了", editInStudio: "Studio で編集", @@ -261,10 +262,14 @@ const ja = { remove: "画像 {{index}} を削除", enlarge: "{{name}} を拡大", preview: "画像プレビュー", + cropPreview: "画像の切り抜きプレビュー", previous: "前の画像", next: "次の画像", counter: "{{current}} / {{total}}", }, + percent: { + slider: "パーセンテージ", + }, relativeDate: { overdue: "期限超過 {{count}}日", overdue_one: "期限超過 {{count}}日", @@ -397,6 +402,7 @@ const ja = { selectAllRows: "すべての行を選択", expandRow: "行を展開", collapseRow: "行を折りたたむ", + rowActions: "行のアクション", hideColumn: "列を非表示", freezeColumn: "列を固定", unfreezeColumn: "列の固定を解除", @@ -434,6 +440,7 @@ const ja = { bulkAllOnPage_other: "このページの {{count}} 件がすべて選択されています。", bulkSelectAllMatching: "該当する {{count}} 件すべてを選択", bulkAllMatchingSelected: "該当する {{count}} 件のレコードがすべて選択されています。", + bulkActions: "一括操作", bulk: { confirmDefault: "{{count}} 件のレコードに適用されます。", confirmDefault_one: "{{count}} 件のレコードに適用されます。", @@ -1032,6 +1039,8 @@ const ja = { designer: { undo: "元に戻す", redo: "やり直す", + canvasMinimap: "キャンバスのミニマップ", + versionHistory: "バージョン履歴", }, detail: { editInlineHint: "ダブルクリックで編集", @@ -2535,6 +2544,7 @@ const ja = { appDocs: 'このアプリのドキュメント', allDocs: 'すべてのドキュメント', onlineDocs: 'オンラインドキュメント', + loadingDocumentation: 'ドキュメントを読み込み中', }, sidebar: { packageManagement: "パッケージ管理", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 790ec5fb3c..25e7f54e88 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -127,6 +127,7 @@ const ko = { itemCount_one: "{{count}}개 항목", itemCount_other: "{{count}}개 항목", toggleSidebar: "사이드바 전환", + breadcrumb: "탐색 경로", package: "패키지", done: "완료", editInStudio: "Studio에서 편집", @@ -261,10 +262,14 @@ const ko = { remove: "이미지 {{index}} 삭제", enlarge: "{{name}} 확대", preview: "이미지 미리보기", + cropPreview: "이미지 자르기 미리보기", previous: "이전 이미지", next: "다음 이미지", counter: "{{current}} / {{total}}", }, + percent: { + slider: "백분율", + }, relativeDate: { overdue: "기한 초과 {{count}}일", overdue_one: "기한 초과 {{count}}일", @@ -397,6 +402,7 @@ const ko = { selectAllRows: "모든 행 선택", expandRow: "행 펼치기", collapseRow: "행 접기", + rowActions: "행 작업", hideColumn: "열 숨기기", freezeColumn: "열 고정", unfreezeColumn: "열 고정 해제", @@ -434,6 +440,7 @@ const ko = { bulkAllOnPage_other: "이 페이지의 {{count}}건이 모두 선택되었습니다.", bulkSelectAllMatching: "일치하는 {{count}}건 모두 선택", bulkAllMatchingSelected: "일치하는 레코드 {{count}}건이 모두 선택되었습니다.", + bulkActions: "일괄 작업", bulk: { confirmDefault: "레코드 {{count}}건에 적용됩니다.", confirmDefault_one: "레코드 {{count}}건에 적용됩니다.", @@ -1032,6 +1039,8 @@ const ko = { designer: { undo: "실행 취소", redo: "다시 실행", + canvasMinimap: "캔버스 미니맵", + versionHistory: "버전 기록", }, detail: { editInlineHint: "더블클릭하여 편집", @@ -2533,6 +2542,7 @@ const ko = { appDocs: '이 앱의 문서', allDocs: '전체 문서', onlineDocs: '온라인 문서', + loadingDocumentation: '문서 로딩 중', }, sidebar: { packageManagement: "패키지 관리", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 1ae02ffd3c..421de98598 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -132,6 +132,7 @@ const pt = { itemCount_many: "{{count}} de itens", itemCount_other: "{{count}} itens", toggleSidebar: "Alternar barra lateral", + breadcrumb: "Trilha de navegação", package: "Pacote", done: "Concluído", editInStudio: "Editar no Studio", @@ -266,10 +267,14 @@ const pt = { remove: "Remover a imagem {{index}}", enlarge: "Ampliar {{name}}", preview: "Pré-visualização da imagem", + cropPreview: "Pré-visualização do recorte da imagem", previous: "Imagem anterior", next: "Próxima imagem", counter: "{{current}} / {{total}}", }, + percent: { + slider: "Porcentagem", + }, relativeDate: { overdue: "Atrasado {{count}} d", overdue_one: "Atrasado {{count}} d", @@ -403,6 +408,7 @@ const pt = { selectAllRows: "Selecionar todas as linhas", expandRow: "Expandir linha", collapseRow: "Recolher linha", + rowActions: "Ações da linha", hideColumn: "Ocultar coluna", freezeColumn: "Fixar coluna", unfreezeColumn: "Soltar coluna", @@ -444,6 +450,7 @@ const pt = { bulkAllOnPage_other: "Os {{count}} desta página estão selecionados.", bulkSelectAllMatching: "Selecionar todas as {{count}} correspondências", bulkAllMatchingSelected: "Todos os {{count}} registros correspondentes estão selecionados.", + bulkActions: "Ações em massa", bulk: { confirmDefault: "Isto será aplicado a {{count}} registro(s).", confirmDefault_one: "Isto será aplicado a {{count}} registro.", @@ -1058,6 +1065,8 @@ const pt = { designer: { undo: "Desfazer", redo: "Refazer", + canvasMinimap: "Minimapa da tela", + versionHistory: "Histórico de versões", }, detail: { editInlineHint: "Clique duas vezes para editar", @@ -2578,6 +2587,7 @@ const pt = { appDocs: 'Documentação deste app', allDocs: 'Toda a documentação', onlineDocs: 'Documentação online', + loadingDocumentation: 'Carregando documentação', }, sidebar: { packageManagement: "Gerenciamento de pacotes", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index bb5491c5f3..a863f998e4 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -147,6 +147,7 @@ const ru = { itemCount_many: "{{count}} элементов", itemCount_other: "{{count}} элемента", toggleSidebar: "Переключить боковую панель", + breadcrumb: "Навигационная цепочка", package: "Пакет", done: "Готово", editInStudio: "Редактировать в Studio", @@ -281,10 +282,14 @@ const ru = { remove: "Удалить изображение {{index}}", enlarge: "Увеличить {{name}}", preview: "Предпросмотр изображения", + cropPreview: "Предпросмотр обрезки изображения", previous: "Предыдущее изображение", next: "Следующее изображение", counter: "{{current}} / {{total}}", }, + percent: { + slider: "Процент", + }, relativeDate: { overdue: "Просрочено на {{count}} дн.", overdue_one: "Просрочено на {{count}} дн.", @@ -419,6 +424,7 @@ const ru = { selectAllRows: "Выбрать все строки", expandRow: "Развернуть строку", collapseRow: "Свернуть строку", + rowActions: "Действия со строкой", hideColumn: "Скрыть столбец", freezeColumn: "Закрепить столбец", unfreezeColumn: "Открепить столбец", @@ -464,6 +470,7 @@ const ru = { bulkAllOnPage_other: "Выбраны все записи на этой странице ({{count}}).", bulkSelectAllMatching: "Выбрать все совпадения ({{count}})", bulkAllMatchingSelected: "Выбраны все подходящие записи ({{count}}).", + bulkActions: "Массовые действия", bulk: { confirmDefault: "Будет применено к записям: {{count}}.", confirmDefault_one: "Будет применено к записям: {{count}}.", @@ -1097,6 +1104,8 @@ const ru = { designer: { undo: "Отменить", redo: "Повторить", + canvasMinimap: "Миникарта холста", + versionHistory: "История версий", }, detail: { editInlineHint: "Дважды щёлкните для редактирования", @@ -2645,6 +2654,7 @@ const ru = { appDocs: 'Документация приложения', allDocs: 'Вся документация', onlineDocs: 'Онлайн-документация', + loadingDocumentation: 'Загрузка документации', }, sidebar: { packageManagement: "Управление пакетами", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 65d9a78aca..cbf0a63743 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -134,6 +134,7 @@ const zh = { itemCount_one: '{{count}} 项', itemCount_other: '{{count}} 项', toggleSidebar: '切换侧边栏', + breadcrumb: '面包屑导航', package: '软件包', done: '完成', editInStudio: '在 Studio 中编辑', @@ -291,10 +292,14 @@ const zh = { remove: '移除图片 {{index}}', enlarge: '放大 {{name}}', preview: '图片预览', + cropPreview: '图片裁剪预览', previous: '上一张', next: '下一张', counter: '{{current}} / {{total}}', }, + percent: { + slider: '百分比', + }, richText: { format: '格式: {{format}}', basicEditorHint: '富文本编辑器(基础)', @@ -408,6 +413,7 @@ const zh = { selectAllRows: '选择所有行', expandRow: '展开行', collapseRow: '折叠行', + rowActions: '行操作', hideColumn: '隐藏列', freezeColumn: '冻结列', unfreezeColumn: '取消冻结列', @@ -456,6 +462,7 @@ const zh = { bulkAllOnPage_other: '本页 {{count}} 项已全选。', bulkSelectAllMatching: '选择全部 {{count}} 项匹配记录', bulkAllMatchingSelected: '已选择全部 {{count}} 项匹配记录。', + bulkActions: '批量操作', rowHeight: '行高: {{mode}}', empty: '空', yes: '是', @@ -1057,6 +1064,8 @@ const zh = { designer: { undo: '撤销', redo: '重做', + canvasMinimap: '画布缩略图', + versionHistory: '版本历史', }, detail: { back: '返回', @@ -2623,6 +2632,7 @@ const zh = { appDocs: '本应用文档', allDocs: '全部文档', onlineDocs: '在线文档', + loadingDocumentation: '正在加载文档', }, sidebar: { settings: '设置', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index db5904a4b6..85d70df015 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -1643,9 +1643,15 @@ const ChatbotEnhanced = React.forwardRef( copied: labels?.copied ?? 'Copied', regenerate: labels?.regenerate ?? 'Regenerate', model: labels?.model ?? 'Model', - submit: labels?.submit ?? 'Submit', + // objectui#12145 — the composer's send / stop button and an + // attachment's remove button are icon-only, so these are their + // accessible names. With no host `labels` they read the pack: the + // console's own keys for the two names it already passes, and the + // record attachment list's key for the third — one key per name. + submit: labels?.submit ?? tt('console.ai.submit', 'Submit'), uploadFiles: labels?.uploadFiles ?? 'Upload files', - stopResponse: labels?.stopResponse ?? 'Stop response', + stopResponse: labels?.stopResponse ?? tt('console.ai.stopResponse', 'Stop response'), + removeAttachment: tt('detail.removeAttachment', 'Remove attachment'), sendFailedRateLimited: labels?.sendFailedRateLimited ?? "You're sending messages too quickly. Your message is kept below — wait a moment and try again.", @@ -3602,7 +3608,7 @@ const ChatbotEnhanced = React.forwardRef( {enableFileUpload ? ( - {(attachment) => } + {(attachment) => } ) : null} diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.composerNames.zh-12145.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.composerNames.zh-12145.test.tsx new file mode 100644 index 0000000000..3f5843d568 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.composerNames.zh-12145.test.tsx @@ -0,0 +1,56 @@ +/** + * 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. + */ + +/** + * objectui#12145 — the composer's send button is named from the pack. + * + * The vendored `PromptInputSubmit` defaults to `aria-label="Submit"`, and its one + * shipped call site, `ChatbotEnhanced`, overrides it with `L.submit`. That + * fallback was the English literal itself, so every host that passes no + * `labels` (an SDUI `chatbot` node, a standalone embed) named the button + * "Submit" in every locale. It now reads `console.ai.submit`, the key the + * console already passes for the same name, and the stop state reads + * `console.ai.stopResponse`. + * + * The name is read against the PACK, never against a literal. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { en, zh } from '@object-ui/i18n/locales'; +import { ChatbotEnhanced } from '../ChatbotEnhanced'; + +afterEach(cleanup); + +describe("the composer's send button is named from the pack (objectui#12145)", () => { + it('with no provider and no host labels, it carries the en pack value', () => { + render(); + expect(screen.getByRole('button', { name: en.console.ai.submit })).toBeInTheDocument(); + }); + + it('under zh with no host labels, it is named by the zh pack, not by the English word', async () => { + expect(zh.console.ai.submit).not.toBe(en.console.ai.submit); + render( + + + , + ); + expect(await screen.findByRole('button', { name: zh.console.ai.submit })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: en.console.ai.submit })).toBeNull(); + }); + + it("a host's own label still wins over the pack", async () => { + render( + + + , + ); + expect(await screen.findByRole('button', { name: 'Host send' })).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-chatbot/src/elements/prompt-input.tsx b/packages/plugin-chatbot/src/elements/prompt-input.tsx index 8b98cdf9d4..35a8845802 100644 --- a/packages/plugin-chatbot/src/elements/prompt-input.tsx +++ b/packages/plugin-chatbot/src/elements/prompt-input.tsx @@ -298,11 +298,20 @@ export const usePromptInputAttachments = () => { export type PromptInputAttachmentProps = HTMLAttributes & { data: PromptInputFileItem; className?: string; + /** + * The remove button's accessible name. ObjectUI addition to the vendored + * element (objectui#12145), in the same spirit as `ToolHeader`'s + * `statusLabels` (objectui#7254): `props` reach the chip, not the button + * inside it, so a caller had no way to name the button in its own language. + * Additive and optional: omitted, the upstream English name stays. + */ + removeLabel?: string; }; export function PromptInputAttachment({ data, className, + removeLabel = "Remove attachment", ...props }: PromptInputAttachmentProps) { const attachments = usePromptInputAttachments(); @@ -343,7 +352,7 @@ export function PromptInputAttachment({ )}