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',