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
27 changes: 27 additions & 0 deletions .changeset/detail-drawer-resize-handle-i18n-5733.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize drawer"
aria-label={t('common.resizeDrawer')}
onPointerDown={handleResizePointerDown}
className="hidden sm:block absolute left-0 top-0 h-full w-1.5 cursor-col-resize select-none bg-transparent hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="dv-probe" />,
}));

vi.mock('../InlineEditSaveBar', () => ({
InlineEditSaveBar: () => null,
}));

function renderDrawerIn(
language: string,
extra: Partial<React.ComponentProps<typeof RecordDetailDrawer>> = {},
) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RecordDetailDrawer
open
onClose={() => {}}
title="Task Details"
record={{ id: '1', name: 'Hello' }}
objectName="tasks"
recordId="1"
{...extra}
/>
</I18nProvider>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
@@ -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 `<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 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: () => <div data-testid="dv-probe" />,
}));

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(
<RecordDetailDrawer
open
onClose={() => {}}
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();
});
});
7 changes: 7 additions & 0 deletions packages/plugin-detail/src/useDetailTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export const createSafeTranslationHook = createSafeTranslation;
* Used as fallback when no I18nProvider is available.
*/
export const DETAIL_DEFAULT_TRANSLATIONS: Record<string, string> = {
// 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',
Expand Down
Loading