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
46 changes: 46 additions & 0 deletions .changeset/overlay-tabs-chrome-i18n-5506.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/components': patch
'@object-ui/i18n': patch
---

Localize the record-overlay and tab-badge chrome that #5430's sweep left behind (objectstack#5506)

Four more console-chrome strings were still hardcoded English literals. Unlike
#5430's set they are not all accessible names — one is visible copy, and one was
a component **default** that only the console happened to override.

- `page:tabs`' count badge built its `aria-label` by template literal,
`` `${formatTabCount(count)} items` ``. The badge renders digits only, so that
label *is* the badge to a screen reader — and the English plural was baked in
with no singular branch at all, so a related list with one row announced
"1 items". Now `common.itemCount` / `common.itemCountOne`.
- `NavigationOverlay`'s drag-resize handle (`role="separator"`, no visible label)
— now `common.resizeDrawer`.
- `NavigationOverlay`'s `expandLabel` **default**. Hosts may override it and the
console does, but the default is what every other host ships — and it feeds
both `aria-label` and `title` of an icon-only button. Now
`detail.openAsFullPage`, still overridable by the prop.
- `NavigationOverlay`'s `resolvedTitle` fallback, `'Record Detail'` — **visible**
overlay heading, not just an a11y name. Now `detail.recordDetail`.
- The sr-only `SheetDescription`/`DialogDescription` prose
`Record detail overlay for {title}.`, which existed in three copies
(drawer / modal / popover) — now one `detail.recordDetailOverlay` key with a
`{{title}}` placeholder.

The count badge follows this repo's **two-key** plural convention
(`detail.reactionCount`/`reactionCountOne`, `detail.relatedRecords`/`relatedRecordOne`)
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. The formatted count
(`1.2k`, not `1200`) is interpolated so the accessible name and the visible
digits never disagree — and because i18next skips its own plural resolution when
`count` is a string, the two-key scheme stays in charge of the choice.

Both touched components moved from `useSafeTranslate` to `createSafeTranslation`,
which carries an options bag (two of the new keys interpolate) and an English
defaults map. That map is what keeps the provider-less path English, which
consumers outside this package depend on — `plugin-view`'s `ObjectView.test.tsx`
and `e2e/live/inline-edit-polish-2572.spec.ts` address this chrome by English
accessible name with no `I18nProvider` mounted.

All six new keys are added to all ten locale packs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* 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.
*/

/**
* Every chrome string objectstack#5506 moved into the locale packs still
* resolves to ENGLISH when no `I18nProvider` is mounted.
*
* This is not a nice-to-have: consumers outside this package address exactly
* these controls by their English accessible names with no provider in the
* tree — `packages/plugin-view/src/__tests__/ObjectView.test.tsx`
* (`getByLabelText('Close panel')`) and `e2e/live/inline-edit-polish-2572.spec.ts`
* (the header toolbar by English name). Routing a literal through `t()` without
* a working default is exactly how that breaks, and it breaks in another
* package's suite, not this one's.
*
* ── Why this is its own FILE, not a describe block ────────────────────────
* `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next`
* registers that instance as **react-i18next's module-global default**. The
* registration survives unmount and `cleanup()`. So the moment any test in a
* file mounts `<I18nProvider config={{ defaultLanguage: 'de' }}>`, every later
* "no provider" render in that same file silently resolves against the German
* instance — a green-looking file that asserts nothing about the fallback, or
* (as first written) a confusing red where the drawer renders
* "Als ganze Seite öffnen" under a test that mounted no provider at all.
*
* Vitest's `dom` project runs with `isolate: true`, so a file that never mounts
* a provider gets a genuinely clean global. Keep it that way: **do not import
* or mount `I18nProvider` here.**
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { SchemaRenderer } from '@object-ui/react';
import { NavigationOverlay } from '../custom/navigation-overlay';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

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

afterEach(() => cleanup());

describe('NavigationOverlay chrome — English fallback with no provider (objectstack#5506)', () => {
it('names the resize handle, expand button, heading and description in English', () => {
render(
<NavigationOverlay
isOpen
isOverlay
selectedRecord={record}
mode="drawer"
close={() => {}}
setIsOpen={() => {}}
storageKey="drawer-width:lead"
onExpand={() => {}}
>
{() => <div>BODY CONTENT</div>}
</NavigationOverlay>,
);

// Drag handle — `role="separator"`, no visible label.
expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Resize drawer');
// Expand button — icon-only; `title` is the precise handle because the
// shadcn Sheet primitive contributes an untranslated close of its own.
expect(screen.getByTitle('Open as full page').getAttribute('aria-label')).toBe(
'Open as full page',
);
// Visible heading, with no host-supplied title.
expect(screen.getByText('Record Detail')).toBeTruthy();
// sr-only description, interpolating that same default heading.
expect(screen.getByText('Record detail overlay for Record Detail.')).toBeTruthy();
});

it('keeps the #5430 close names English too', () => {
render(
<NavigationOverlay
isOpen
isOverlay
selectedRecord={record}
mode="split"
close={() => {}}
setIsOpen={() => {}}
title="Acme Corp"
mainContent={<div>main</div>}
>
{() => <div>BODY CONTENT</div>}
</NavigationOverlay>,
);

// The exact query `plugin-view`'s ObjectView.test.tsx uses.
expect(screen.getByLabelText('Close panel')).toBeTruthy();
});
});

describe('page:tabs count badge — English fallback with no provider (objectstack#5506)', () => {
it('names the badge in English, with a real singular form', () => {
render(
<SchemaRenderer
schema={{
type: 'page:tabs',
id: 'tabs',
items: [
{
label: 'Details',
value: 'details',
count: 5,
children: [{ type: 'element:text', properties: { content: 'A' } }],
},
{
label: 'Related',
value: 'related',
count: 1,
children: [{ type: 'element:text', properties: { content: 'B' } }],
},
],
}}
/>,
);

expect(screen.getByLabelText('5 items')).toBeTruthy();
// Pre-#5506 this read "1 items" — the English plural was baked into the
// template literal with no singular branch at all.
expect(screen.getByLabelText('1 item')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* 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 rest of the record overlay's chrome speaks the session locale —
* objectstack#5506 (follow-on to #5430, which covered only the two close
* affordances).
*
* Four more strings were hardcoded English literals in `NavigationOverlay`:
*
* - the drag-resize handle's `aria-label` on `role="separator"`. The handle
* has no visible label at all, so the literal WAS the control.
* - `expandLabel`'s **default**. Callers may override it (the console's
* `ObjectView` does), but the default is what ships to every other host —
* and it feeds both `aria-label` and `title` of an icon-only button.
* - `resolvedTitle`'s fallback, `'Record Detail'`. This one is **visible**
* copy, not just an accessible name.
* - the sr-only `SheetDescription`/`DialogDescription` prose, which existed
* in three copies (drawer / modal / popover) and interpolates the title.
*
* Addressing convention, inherited from `navigation-overlay-close-i18n.test.tsx`:
* the shadcn `Sheet` primitive auto-renders a close button whose only name is a
* hardcoded English `sr-only` span (an upstream No-Touch zone, AGENTS.md #7).
* `NavigationOverlay` CSS-hides it, but happy-dom applies no Tailwind, so it is
* still in the tree — never address anything here by a bare name of "Close".
*
* Direction of these assertions: `en` was already green before the change (the
* English default has to survive), and every non-English case was red. The
* provider-less fallback is asserted in `chrome-i18n-no-provider-fallback.test.tsx`
* — it cannot live in this file, see that file's header for why.
*/

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' };

type DrawerOpts = {
/** Omitted on purpose in the default-title cases. */
title?: string;
/** Presence of a storageKey is what renders the drag-resize handle. */
storageKey?: string;
onExpand?: () => void;
expandLabel?: string;
};

function renderDrawer(language: string, opts: DrawerOpts = {}) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<NavigationOverlay
isOpen
isOverlay
selectedRecord={record}
mode="drawer"
close={() => {}}
setIsOpen={() => {}}
{...opts}
>
{/* Deliberately NOT the record's name: the header title renders it too,
and a body echo makes `getByText(title)` ambiguous. */}
{() => <div>BODY CONTENT</div>}
</NavigationOverlay>
</I18nProvider>,
);
}

afterEach(() => cleanup());

describe('NavigationOverlay drag-resize handle — accessible name (objectstack#5506)', () => {
it('still reads English under an en session', () => {
renderDrawer('en', { title: 'Acme Corp', storageKey: 'drawer-width:lead' });

expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Resize drawer');
});

it('reads the zh bundle value under a zh session', () => {
renderDrawer('zh', { title: 'Acme Corp', storageKey: 'drawer-width:lead' });

expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('调整面板宽度');
expect(screen.queryByLabelText('Resize drawer')).toBeNull();
});

it('reads the de bundle value under a de session', () => {
renderDrawer('de', { title: 'Acme Corp', storageKey: 'drawer-width:lead' });

expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Panelbreite anpassen');
});
});

describe('NavigationOverlay expand button — default label (objectstack#5506)', () => {
it('still reads English under an en session', () => {
renderDrawer('en', { title: 'Acme Corp', onExpand: () => {} });

// Ours is the only expand affordance, and it carries both name and title.
expect(screen.getByTitle('Open as full page').getAttribute('aria-label')).toBe(
'Open as full page',
);
});

it('reads the zh bundle value under a zh session', () => {
renderDrawer('zh', { title: 'Acme Corp', onExpand: () => {} });

expect(screen.getByTitle('以完整页面打开').getAttribute('aria-label')).toBe('以完整页面打开');
expect(screen.queryByTitle('Open as full page')).toBeNull();
});

it('reads the ja bundle value under a ja session', () => {
renderDrawer('ja', { title: 'Acme Corp', onExpand: () => {} });

expect(screen.getByTitle('フルページで開く')).toBeTruthy();
});

/**
* The prop is still an override, not merely a default hint — the console
* passes its own `console.objectView.expandToPage` value through it, and that
* must keep winning over the component's own key.
*/
it("a host-supplied expandLabel still wins over the locale's default", () => {
renderDrawer('zh', {
title: 'Acme Corp',
onExpand: () => {},
expandLabel: '主机自定义标签',
});

expect(screen.getByTitle('主机自定义标签')).toBeTruthy();
expect(screen.queryByTitle('以完整页面打开')).toBeNull();
});
});

describe('NavigationOverlay default heading — visible copy (objectstack#5506)', () => {
it('still reads English under an en session', () => {
renderDrawer('en');

expect(screen.getByText('Record Detail')).toBeTruthy();
});

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

expect(screen.getByText('记录详情')).toBeTruthy();
expect(screen.queryByText('Record Detail')).toBeNull();
});

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

expect(screen.getByText('레코드 세부 정보')).toBeTruthy();
});

it('a host-supplied title still wins in every locale', () => {
renderDrawer('zh', { title: 'Acme Corp' });

expect(screen.getByText('Acme Corp')).toBeTruthy();
expect(screen.queryByText('记录详情')).toBeNull();
});
});

describe('NavigationOverlay sr-only description (objectstack#5506)', () => {
it('still reads English under an en session, interpolating the title', () => {
renderDrawer('en', { title: 'Acme Corp' });

expect(screen.getByText('Record detail overlay for Acme Corp.')).toBeTruthy();
});

it('reads the zh bundle value under a zh session', () => {
renderDrawer('zh', { title: 'Acme Corp' });

expect(screen.getByText('Acme Corp 的记录详情浮层。')).toBeTruthy();
expect(screen.queryByText('Record detail overlay for Acme Corp.')).toBeNull();
});

/**
* The description interpolates `resolvedTitle`, so with no host title BOTH
* halves have to come from the pack — a regression that localized the prose
* but left the title fallback English would show up here and nowhere else.
*/
it('interpolates the localized default title when the host passes none', () => {
renderDrawer('zh');

expect(screen.getByText('记录详情 的记录详情浮层。')).toBeTruthy();
});

it('is rendered in modal mode too', () => {
render(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
<NavigationOverlay
isOpen
isOverlay
selectedRecord={record}
mode="modal"
close={() => {}}
setIsOpen={() => {}}
title="Acme Corp"
>
{() => <div>BODY CONTENT</div>}
</NavigationOverlay>
</I18nProvider>,
);

expect(screen.getByText('Acme Corp 的记录详情浮层。')).toBeTruthy();
});
});
Loading
Loading