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
29 changes: 29 additions & 0 deletions .changeset/chrome-a11y-names-i18n-5430.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/components': patch
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

Localize the last untranslated console-chrome accessible names (objectstack#5430)

Four icon-only controls still carried hardcoded English accessible names, so
under a non-English session they were the only English left in the record
chrome — and because the controls have no visible label, that literal *is* the
control to a screen reader and to the hover tooltip.

- `page:header`'s `role="toolbar"` — now `detail.pageHeaderActions` (its `⋯`
overflow trigger eight lines below was fixed in #5407; the toolbar was missed)
- `ReactionPicker`'s `role="listbox"` popup — now `detail.emojiPicker`
- `ReactionPicker`'s per-reaction chip, which built its name by concatenation
with English pluralization baked in (`reaction${count !== 1 ? 's' : ''}`) —
now `detail.reactionCount` / `detail.reactionCountOne`
- `NavigationOverlay`'s drawer close and split-panel close — now `common.close`
(the key the rest of the console already uses) and `common.closePanel`

The pluralized label follows this repo's **two-key** convention
(`detail.relatedRecords`/`relatedRecordOne`, `lookup.recordCount`/`recordCountOne`)
rather than an i18next `_one`/`_other` pair: zh/ja/ko have no separate singular
form, so those packs would legitimately omit the `_one` half and
`all-locales-key-parity` would read that as a lost key.

All five new keys are added to all ten locale packs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The record overlay's close affordances speak the session locale —
* objectstack#5430.
*
* `NavigationOverlay` had two hardcoded English accessible names:
* - drawer mode: the header `X` button (`aria-label`/`title` = "Close")
* - split mode: the detail panel's `X` button (`aria-label` = "Close panel")
*
* Both are icon-only, so the literal WAS the control to a screen reader and to
* the hover tooltip. They now read `common.close` / `common.closePanel`.
* `common.close` is deliberately the key the rest of the console already uses
* for a bare "Close" rather than a new overlay-private one.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { NavigationOverlay } from '../custom/navigation-overlay';

const record = { _id: 'rec_1', name: 'Acme Corp' };

function renderDrawerIn(language: string) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<NavigationOverlay
isOpen
isOverlay
selectedRecord={record}
mode="drawer"
close={() => {}}
setIsOpen={() => {}}
title="Acme Corp"
>
{(r) => <div>{String(r.name)}</div>}
</NavigationOverlay>
</I18nProvider>,
);
}

function renderSplitIn(language: string) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<NavigationOverlay
isOpen
isOverlay
selectedRecord={record}
mode="split"
close={() => {}}
setIsOpen={() => {}}
title="Acme Corp"
mainContent={<div>main</div>}
>
{(r) => <div>{String(r.name)}</div>}
</NavigationOverlay>
</I18nProvider>,
);
}

afterEach(() => cleanup());

/**
* Addressing the drawer's close button: by `title`, NOT by role+name.
*
* The shadcn `Sheet` primitive auto-renders a close button of its own, whose
* only accessible name is a hardcoded English `sr-only` span
* (`packages/components/src/ui/sheet.tsx:80` — an upstream No-Touch zone,
* AGENTS.md #7). `NavigationOverlay` CSS-hides it with
* `[&>button:last-of-type]:hidden`, so a real browser drops it from the
* accessibility tree — but jsdom does not apply Tailwind, so RTL still sees it
* and `getByRole('button', { name: 'Close' })` matches two elements under `en`.
*
* Ours is the only close carrying a `title`, so that is the precise handle. The
* primitive's own untranslated label is a separate, out-of-scope finding.
*/
describe('NavigationOverlay drawer close — accessible name (objectstack#5430)', () => {
it('still reads English under an en session', () => {
renderDrawerIn('en');

expect(screen.getByTitle('Close').getAttribute('aria-label')).toBe('Close');
});

it('reads the zh bundle value under a zh session', () => {
renderDrawerIn('zh');

expect(screen.getByTitle('关闭').getAttribute('aria-label')).toBe('关闭');
expect(screen.getByRole('button', { name: '关闭' })).toBeTruthy();
// The literal this replaced, scoped to OUR button via `title` so the
// primitive's hidden English one cannot mask a re-inlined string here.
expect(screen.queryByTitle('Close')).toBeNull();
});

it('reads the de bundle value under a de session', () => {
renderDrawerIn('de');

expect(screen.getByTitle('Schließen').getAttribute('aria-label')).toBe('Schließen');
expect(screen.getByRole('button', { name: 'Schließen' })).toBeTruthy();
expect(screen.queryByTitle('Close')).toBeNull();
});
});

describe('NavigationOverlay split close panel — accessible name (objectstack#5430)', () => {
it('still reads English under an en session', () => {
renderSplitIn('en');

expect(screen.getByRole('button', { name: 'Close panel' })).toBeTruthy();
});

it('reads the zh bundle value under a zh session', () => {
renderSplitIn('zh');

expect(screen.getByRole('button', { name: '关闭面板' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Close panel' })).toBeNull();
});

it('reads the ja bundle value under a ja session', () => {
renderSplitIn('ja');

expect(screen.getByRole('button', { name: 'パネルを閉じる' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Close panel' })).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The page header's action toolbar speaks the session locale —
* objectstack#5430.
*
* `page:header` wraps its action buttons in a `role="toolbar"` whose accessible
* name was the English literal "Page header actions". A toolbar has no visible
* label, so that literal IS the group as far as a screen reader is concerned:
* under a zh/ja session it announced the only English left in the header row.
* (#5407 fixed the `⋯` trigger eight lines below it and missed this one.)
*
* It now reads `detail.pageHeaderActions`.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { ActionProvider } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
// Registers `page:header` at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout` (objectui#3010/#3021).
import '../renderers';

function PageHeader({ schema }: { schema: any }) {
const Component = ComponentRegistry.get('page:header');
if (!Component) throw new Error('page:header not registered');
// eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render
return <Component schema={schema} />;
}

const schema = {
type: 'page:header',
title: 'Acme Corp',
actions: [
{ name: 'convert', locations: ['record_header'], label: 'Convert', type: 'flow' },
{ name: 'clone', locations: ['record_header'], label: 'Clone', type: 'flow' },
],
};

function renderHeaderIn(language: string) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<ActionProvider>
<PageHeader schema={schema} />
</ActionProvider>
</I18nProvider>,
);
}

afterEach(() => cleanup());

describe('page:header action toolbar — accessible name (objectstack#5430)', () => {
it('still reads English under an en session', () => {
renderHeaderIn('en');

expect(screen.getByRole('toolbar', { name: 'Page header actions' })).toBeTruthy();
});

it('reads the zh bundle value under a zh session', () => {
renderHeaderIn('zh');

expect(screen.getByRole('toolbar', { name: '页面标题栏操作' })).toBeTruthy();
// The literal this replaced. Asserted negatively so a re-inlined English
// string cannot pass by rendering a second, untranslated toolbar.
expect(screen.queryByRole('toolbar', { name: 'Page header actions' })).toBeNull();
});

it('reads the ja bundle value under a ja session', () => {
renderHeaderIn('ja');

expect(screen.getByRole('toolbar', { name: 'ページヘッダーの操作' })).toBeTruthy();
expect(screen.queryByRole('toolbar', { name: 'Page header actions' })).toBeNull();
});
});
11 changes: 8 additions & 3 deletions packages/components/src/custom/navigation-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
ResizableHandle,
} from './resizable';
import { usePopperAwareInteractOutside } from './mobile-dialog-content';
import { useSafeTranslate } from '@object-ui/i18n';

/** Navigation mode type — matches ViewNavigationConfig.mode */
export type NavigationOverlayMode =
Expand Down Expand Up @@ -286,6 +287,10 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
// Inline-edit dropdowns render in body-level poppers; without this guard the
// click that closes an open dropdown also dismisses the drawer/modal (#2156).
const handleInteractOutside = usePopperAwareInteractOutside();
// Both close affordances below are icon-only, so their accessible name IS
// the control to a screen reader and to the hover tooltip (objectstack#5430).
// Must stay above the conditional returns — rules-of-hooks.
const tt = useSafeTranslate();

// Non-overlay modes don't render anything
if (mode === 'page' || mode === 'new_window' || mode === 'none') {
Expand Down Expand Up @@ -388,8 +393,8 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
<SheetClose asChild>
<button
type="button"
aria-label="Close"
title="Close"
aria-label={tt('common.close', 'Close')}
title={tt('common.close', 'Close')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
Expand Down Expand Up @@ -473,7 +478,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
<button
onClick={close}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Close panel"
aria-label={tt('common.closePanel', 'Close panel')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1198,7 +1198,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
<div
className="flex flex-wrap items-center gap-2 shrink-0"
role="toolbar"
aria-label="Page header actions"
aria-label={tt('detail.pageHeaderActions', 'Page header actions')}
>
{inlineActions.map(renderButton)}
{useOverflow && (
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const ar = {
select: "اختر...",
openChat: "فتح المحادثة",
closeChat: "إغلاق المحادثة",
closePanel: "إغلاق اللوحة",
toggleSidebar: "تبديل الشريط الجانبي",
package: "الحزمة",
},
Expand Down Expand Up @@ -737,6 +738,10 @@ const ar = {
delete: "حذف",
moreActions: "المزيد من الإجراءات",
addReaction: "إضافة تفاعل",
pageHeaderActions: "إجراءات رأس الصفحة",
emojiPicker: "منتقي الرموز التعبيرية",
reactionCount: "{{emoji}} {{count}} تفاعلات",
reactionCountOne: "{{emoji}} {{count}} تفاعل",
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
previousRecord: "السجل السابق",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const de = {
select: "Auswählen...",
openChat: "Chat öffnen",
closeChat: "Chat schließen",
closePanel: "Panel schließen",
toggleSidebar: "Seitenleiste umschalten",
package: "Paket",
},
Expand Down Expand Up @@ -735,6 +736,10 @@ const de = {
delete: "Löschen",
moreActions: "Weitere Aktionen",
addReaction: "Reaktion hinzufügen",
pageHeaderActions: "Aktionen der Seitenkopfzeile",
emojiPicker: "Emoji-Auswahl",
reactionCount: "{{emoji}} {{count}} Reaktionen",
reactionCountOne: "{{emoji}} {{count}} Reaktion",
addToFavorites: "Zu Favoriten hinzufügen",
removeFromFavorites: "Aus Favoriten entfernen",
previousRecord: "Vorheriger Datensatz",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const en = {
select: 'Select...',
openChat: 'Open chat',
closeChat: 'Close chat',
closePanel: 'Close panel',
toggleSidebar: 'Toggle sidebar',
package: 'Package',
},
Expand Down Expand Up @@ -787,6 +788,10 @@ const en = {
delete: 'Delete',
moreActions: 'More actions',
addReaction: 'Add reaction',
pageHeaderActions: 'Page header actions',
emojiPicker: 'Emoji picker',
reactionCount: '{{emoji}} {{count}} reactions',
reactionCountOne: '{{emoji}} {{count}} reaction',
addToFavorites: 'Add to favorites',
removeFromFavorites: 'Remove from favorites',
previousRecord: 'Previous record',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const es = {
select: "Seleccionar...",
openChat: "Abrir chat",
closeChat: "Cerrar chat",
closePanel: "Cerrar panel",
toggleSidebar: "Alternar barra lateral",
package: "Paquete",
},
Expand Down Expand Up @@ -740,6 +741,10 @@ const es = {
delete: "Eliminar",
moreActions: "Más acciones",
addReaction: "Añadir reacción",
pageHeaderActions: "Acciones del encabezado de página",
emojiPicker: "Selector de emojis",
reactionCount: "{{emoji}} {{count}} reacciones",
reactionCountOne: "{{emoji}} {{count}} reacción",
addToFavorites: "Añadir a favoritos",
removeFromFavorites: "Quitar de favoritos",
previousRecord: "Registro anterior",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const fr = {
select: "Sélectionner...",
openChat: "Ouvrir le chat",
closeChat: "Fermer le chat",
closePanel: "Fermer le panneau",
toggleSidebar: "Basculer la barre latérale",
package: "Package",
},
Expand Down Expand Up @@ -737,6 +738,10 @@ const fr = {
delete: "Supprimer",
moreActions: "Plus d'actions",
addReaction: "Ajouter une réaction",
pageHeaderActions: "Actions de l'en-tête de page",
emojiPicker: "Sélecteur d'émojis",
reactionCount: "{{emoji}} {{count}} réactions",
reactionCountOne: "{{emoji}} {{count}} réaction",
addToFavorites: "Ajouter aux favoris",
removeFromFavorites: "Retirer des favoris",
previousRecord: "Enregistrement précédent",
Expand Down
Loading
Loading