diff --git a/.changeset/detail-drawer-resize-handle-i18n-5733.md b/.changeset/detail-drawer-resize-handle-i18n-5733.md new file mode 100644 index 0000000000..879e7827bb --- /dev/null +++ b/.changeset/detail-drawer-resize-handle-i18n-5733.md @@ -0,0 +1,27 @@ +--- +'@object-ui/plugin-detail': patch +--- + +Localize `RecordDetailDrawer`'s drag-resize handle (objectstack#5733) + +`packages/plugin-detail/src/RecordDetailDrawer.tsx` carried a byte-identical twin +of the literal objectstack#5506 removed from `NavigationOverlay`: a +`role="separator"` drag handle on the drawer's left edge with a hardcoded +`aria-label="Resize drawer"`. #5506's sweep fenced on `packages/components`, so +this second copy survived it. + +The handle has no visible label, so that string IS the control as far as a +screen reader is concerned — a zh/ja/de session got one English announcement in +an otherwise localized drawer. It is not a dormant branch either: `resizable` +defaults to `true`, and the drawer is what plugin-kanban / plugin-calendar / +plugin-gantt open on row, card and event click. + +It now reads `t('common.resizeDrawer')` — deliberately the SAME key #5506 gave +the other handle (already present in all ten locale packs) rather than a new +`detail.resizeDrawer` twin, so one control rendered from two packages cannot end +up with two translations that drift apart. + +`common.resizeDrawer` is also added to `DETAIL_DEFAULT_TRANSLATIONS`, the map +`createSafeTranslation` falls back to when no `I18nProvider` is mounted. Without +that entry the name would degrade to the raw key for every provider-less host — +which is the regression the accompanying no-provider test pins. diff --git a/packages/plugin-detail/src/RecordDetailDrawer.tsx b/packages/plugin-detail/src/RecordDetailDrawer.tsx index 353b9df5ed..2dfda410ac 100644 --- a/packages/plugin-detail/src/RecordDetailDrawer.tsx +++ b/packages/plugin-detail/src/RecordDetailDrawer.tsx @@ -269,12 +269,15 @@ export function RecordDetailDrawer({ onOpenAutoFocus={(e) => e.preventDefault()} > {/* Drag handle on the left edge — only rendered on >= sm screens - where pointer-resize is meaningful. */} + where pointer-resize is meaningful. The handle carries no visible + label, so its `aria-label` IS the control to a screen reader — + hence it comes from the locale pack, not a literal + (objectstack#5733, twin of #5506's NavigationOverlay handle). */} {resizable && (
diff --git a/packages/plugin-detail/src/__tests__/RecordDetailDrawer.resizeHandleI18n.test.tsx b/packages/plugin-detail/src/__tests__/RecordDetailDrawer.resizeHandleI18n.test.tsx new file mode 100644 index 0000000000..05c72a7ef0 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordDetailDrawer.resizeHandleI18n.test.tsx @@ -0,0 +1,117 @@ +/** + * 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. + */ + +/** + * `RecordDetailDrawer`'s drag-resize handle speaks the session locale — + * objectstack#5733. + * + * The literal removed here was a **byte-identical twin** of the one #5506 + * removed from `NavigationOverlay` (`packages/components/src/custom/ + * navigation-overlay.tsx`): same control (a drag-resize handle on a record + * drawer's left edge), same shape (`role="separator"`, `aria-orientation= + * "vertical"`, no visible label), same defect (a zh/ja/de session got one + * English string in an otherwise localized drawer). #5506's file fence did not + * reach `packages/plugin-detail`, so this copy survived that sweep. + * + * It reuses #5506's key, `common.resizeDrawer` — already in all ten packs — + * rather than minting a `detail.resizeDrawer` twin: one control rendered from + * two packages should not get two translations that can drift apart. + * + * The handle carries no visible label, so its `aria-label` IS the control as + * far as a screen reader is concerned. `resizable` defaults to `true`, so this + * is the drawer the console renders, not a dormant branch. + * + * ── Direction of these assertions ───────────────────────────────────────── + * The non-English cases (zh / de / ja) were RED before the change — the handle + * announced "Resize drawer" in every locale — and are GREEN after. The `en` + * case was GREEN before AND after: it pins that routing the name through `t()` + * did not change what an English session hears. The provider-less fallback is + * asserted in `RecordDetailDrawer.resizeHandleNoProviderFallback.test.tsx` — + * it cannot live in this file; see that file's header for why. + * + * `DetailView` / `InlineEditSaveBar` are mocked (same as + * `RecordDetailDrawer.capability.test.tsx`) so the only `role="separator"` in + * the tree is the handle under test, and the drawer body's data fetching stays + * out of an assertion about chrome. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { RecordDetailDrawer } from '../RecordDetailDrawer'; + +vi.mock('../DetailView', () => ({ + DetailView: () =>
, +})); + +vi.mock('../InlineEditSaveBar', () => ({ + InlineEditSaveBar: () => null, +})); + +function renderDrawerIn( + language: string, + extra: Partial> = {}, +) { + return render( + + {}} + title="Task Details" + record={{ id: '1', name: 'Hello' }} + objectName="tasks" + recordId="1" + {...extra} + /> + , + ); +} + +afterEach(() => cleanup()); + +describe('RecordDetailDrawer drag-resize handle — accessible name (objectstack#5733)', () => { + it('still reads English under an en session', () => { + renderDrawerIn('en'); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Resize drawer'); + }); + + it('reads the zh bundle value under a zh session', () => { + renderDrawerIn('zh'); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('调整面板宽度'); + // The whole point of the issue: no English leaks into a zh drawer. + expect(screen.queryByLabelText('Resize drawer')).toBeNull(); + }); + + it('reads the de bundle value under a de session', () => { + renderDrawerIn('de'); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('Panelbreite anpassen'); + }); + + it('reads the ja bundle value under a ja session', () => { + renderDrawerIn('ja'); + + expect(screen.getByRole('separator').getAttribute('aria-label')).toBe('パネル幅を調整'); + }); + + /** + * The handle is gated on `resizable`, so a host that turns resizing off must + * render no separator at all — in any locale. Without this the locale + * assertions above could pass against an empty tree if the gate ever broke + * open in the other direction. + */ + it('renders no handle at all when the host disables resizing', () => { + renderDrawerIn('zh', { resizable: false }); + + expect(screen.queryByRole('separator')).toBeNull(); + expect(screen.queryByLabelText('调整面板宽度')).toBeNull(); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/RecordDetailDrawer.resizeHandleNoProviderFallback.test.tsx b/packages/plugin-detail/src/__tests__/RecordDetailDrawer.resizeHandleNoProviderFallback.test.tsx new file mode 100644 index 0000000000..c080609fe4 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordDetailDrawer.resizeHandleNoProviderFallback.test.tsx @@ -0,0 +1,79 @@ +/** + * 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. + */ + +/** + * `RecordDetailDrawer`'s resize-handle name still resolves to ENGLISH when no + * `I18nProvider` is mounted — objectstack#5733. + * + * This is not a nice-to-have. Routing a literal through `t()` without a working + * default is exactly how a provider-less consumer breaks, and it breaks in a + * suite that is not this one. `RecordDetailDrawer` is embedded by + * `plugin-kanban`'s `ObjectKanban` and `plugin-calendar`'s `ObjectCalendar` + * (and mocked out by `plugin-gantt`'s), none of which wrap it in an + * `I18nProvider`; the same goes for any host app that never mounts one. The + * English default lives in `DETAIL_DEFAULT_TRANSLATIONS` + * (`useDetailTranslation.ts`) — that map is what `createSafeTranslation` falls + * back to when its `detail.back` probe comes back unresolved. + * + * Direction: this file was GREEN before the change and is GREEN after. It pins + * the FALLBACK, not the fix — the fix is asserted in + * `RecordDetailDrawer.resizeHandleI18n.test.tsx`. A missing map entry would + * have turned it red by rendering the raw key `common.resizeDrawer`, which is + * precisely the regression it exists to catch. + * + * ── 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 ``, 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 a + * confusing red where the drawer announces "Panelbreite anpassen" 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 React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { RecordDetailDrawer } from '../RecordDetailDrawer'; + +vi.mock('../DetailView', () => ({ + DetailView: () =>
, +})); + +vi.mock('../InlineEditSaveBar', () => ({ + InlineEditSaveBar: () => null, +})); + +afterEach(() => cleanup()); + +describe('RecordDetailDrawer resize handle — English fallback with no provider (objectstack#5733)', () => { + it('names the drag handle in English, never the raw key', () => { + render( + {}} + title="Task Details" + record={{ id: '1', name: 'Hello' }} + objectName="tasks" + recordId="1" + />, + ); + + const handle = screen.getByRole('separator'); + expect(handle.getAttribute('aria-label')).toBe('Resize drawer'); + // A missing DETAIL_DEFAULT_TRANSLATIONS entry surfaces as the raw key, + // because createSafeTranslation's fallback is `defaults[key] || key`. + expect(handle.getAttribute('aria-label')).not.toBe('common.resizeDrawer'); + expect(screen.getByLabelText('Resize drawer')).toBeTruthy(); + }); +}); diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index 2b39518d26..f9c08f7271 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -27,6 +27,13 @@ export const createSafeTranslationHook = createSafeTranslation; * Used as fallback when no I18nProvider is available. */ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { + // objectstack#5733 — RecordDetailDrawer's drag-resize handle. The only + // `common.*` key in this map, deliberately: it is the SAME key #5506 gave + // NavigationOverlay's identical handle (`common.resizeDrawer`, already in + // all ten packs), and one control should not get two spellings just because + // it is rendered from two packages. Adding a `detail.resizeDrawer` twin + // would fork the translation and drift the two handles apart. + 'common.resizeDrawer': 'Resize drawer', 'detail.back': 'Back', 'detail.edit': 'Edit', 'detail.editInline': 'Edit',