Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/12145-aria-label-i18n.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion apps/console/src/pages/AppDocsIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Book[]>(
Expand All @@ -37,7 +39,7 @@ export default function AppDocsIndex() {
if (state === 'loading') {
return (
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
</div>
);
}
Expand Down
4 changes: 3 additions & 1 deletion apps/console/src/pages/BookPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand All @@ -54,7 +56,7 @@ export default function BookPage() {
if (state === 'loading') {
return (
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/pages/DocPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export default function DocPage() {
if (state === 'loading') {
return (
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
</div>
);
}
Expand Down
4 changes: 3 additions & 1 deletion apps/console/src/pages/DocsIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,7 @@ import { buildBookCards } from './book-nav';
* reader lives at `/docs/<slug>/<name>`.
*/
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.
Expand All @@ -31,7 +33,7 @@ export default function DocsIndex() {
if (state === 'loading') {
return (
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
</div>
);
}
Expand Down
4 changes: 3 additions & 1 deletion apps/console/src/pages/DocsSlug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -97,7 +99,7 @@ export default function DocsSlug() {
if (state === 'loading' || (unanswered && !!adapter && answer?.slug !== slug)) {
return (
<div className="flex h-full items-center justify-center p-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-label="Loading documentation" />
<Loader2 className="h-5 w-5 animate-spin" aria-label={t('help.loadingDocumentation', { defaultValue: 'Loading documentation' })} />
</div>
);
}
Expand Down
60 changes: 60 additions & 0 deletions apps/console/src/pages/docsLoadingName-12145.test.tsx
Original file line number Diff line number Diff line change
@@ -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 `<svg>`, and testing-library's role table gives an
* `<svg>` 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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<MemoryRouter>
<DocsIndex />
</MemoryRouter>
</I18nProvider>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));
Expand Down Expand Up @@ -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<Lang, string> = { en: en.common.breadcrumb, 'zh-CN': zh.common.breadcrumb };

/** The catalog as the console loads it: `zh-CN` translates both scalars. */
const BUNDLE: Record<Lang, Record<string, unknown>> = {
en: {},
Expand Down Expand Up @@ -119,8 +123,11 @@ async function pageText(lang: Lang, mode: 'create' | 'edit', objectName: string)
</I18nProvider>,
);
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'));
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
>
<ArrowLeft className="h-4 w-4" />
</Button>
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm text-muted-foreground">
<nav aria-label={t('common.breadcrumb')} className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
to={objectListUrl}
className="hover:text-foreground transition-colors"
Expand Down
106 changes: 106 additions & 0 deletions packages/components/src/__tests__/ariaLabelNames.zh-12145.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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/components`' accessible names are read from
* the pack, so under `zh` a screen reader hears the zh pack's words.
*
* - `Spinner`'s status region: `common.loading` (was `"Loading"`).
* - the data table's row menu trigger: `table.rowActions` (was `"Row actions"`).
* - the `pagination` renderer's previous / next links: `table.previousPage` /
* `table.nextPage`. The No-Touch Shadcn primitive keeps its English
* defaults; this call site names the links, since the primitive spreads
* `props` after them.
*
* Every name is read against the PACK, never against a literal, and each zh
* case first proves the two packs disagree, or it would pin nothing.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { PredicateScopeProvider } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
import { en, zh } from '@object-ui/i18n/locales';
import { Spinner } from '../custom/spinner';
// Registers `data-table` and `pagination`.
import '../renderers/complex/data-table';
import '../renderers/basic/pagination';

afterEach(cleanup);

const inZh = (node: React.ReactNode) => (
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
{node}
</I18nProvider>
);

function rowMenuTable() {
const DataTable = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }>;
return (
<PredicateScopeProvider scope={{}}>
<DataTable
schema={{
type: 'data-table',
pagination: false,
searchable: false,
rowActions: true,
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [{ id: 'r1', name: 'Ada' }],
onRowEdit: () => {},
}}
/>
</PredicateScopeProvider>
);
}

function pager() {
const Pagination = ComponentRegistry.get('pagination') as React.ComponentType<{ schema: unknown }>;
return <Pagination schema={{ type: 'pagination', currentPage: 2, totalPages: 3 }} />;
}

describe('components name their controls from the pack (objectui#12145)', () => {
it('with no provider, each name is the en pack value', () => {
render(
<>
<Spinner />
{rowMenuTable()}
{pager()}
</>,
);
expect(screen.getByRole('status', { name: en.common.loading })).toBeInTheDocument();
expect(screen.getByRole('button', { name: en.table.rowActions })).toBeInTheDocument();
expect(screen.getByRole('link', { name: en.table.previousPage })).toBeInTheDocument();
expect(screen.getByRole('link', { name: en.table.nextPage })).toBeInTheDocument();
});

it('under zh, the spinner is named by the zh pack', async () => {
expect(zh.common.loading).not.toBe(en.common.loading);
render(inZh(<Spinner />));
expect(await screen.findByRole('status', { name: zh.common.loading })).toBeInTheDocument();
expect(screen.queryByRole('status', { name: en.common.loading })).toBeNull();
});

it('under zh, the row menu trigger is named by the zh pack', async () => {
expect(zh.table.rowActions).not.toBe(en.table.rowActions);
render(inZh(rowMenuTable()));
expect(await screen.findByRole('button', { name: zh.table.rowActions })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: en.table.rowActions })).toBeNull();
});

it('under zh, the pagination links are named by the zh pack, not by the primitive defaults', async () => {
expect(zh.table.previousPage).not.toBe(en.table.previousPage);
expect(zh.table.nextPage).not.toBe(en.table.nextPage);
render(inZh(pager()));
expect(await screen.findByRole('link', { name: zh.table.previousPage })).toBeInTheDocument();
expect(screen.getByRole('link', { name: zh.table.nextPage })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Go to previous page' })).toBeNull();
expect(screen.queryByRole('link', { name: 'Go to next page' })).toBeNull();
});
});
16 changes: 15 additions & 1 deletion packages/components/src/custom/spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,28 @@
*/

import * as React from "react"
import { createSafeTranslation } from "@object-ui/i18n"
import { cn } from "../lib/utils"
import { Loader2 } from "lucide-react"

/**
* The status region's accessible name reads `common.loading`, the key the rest
* of the tree already uses for a bare "Loading…" (objectui#12145), so a
* screen reader under a non-English locale hears its own language. The row is
* the `en` pack's value, served when no `I18nProvider` is mounted; a caller's
* own `aria-label` still wins, because `props` spread after it.
*/
const useSpinnerTranslation = createSafeTranslation(
{ "common.loading": "Loading…" },
"common.loading",
)

function Spinner({ className, ...props }: React.ComponentProps<"div">) {
const { t } = useSpinnerTranslation()
return (
<div
role="status"
aria-label="Loading"
aria-label={t("common.loading")}
className={cn("flex items-center justify-center", className)}
{...props}
>
Expand Down
Loading
Loading