diff --git a/.changeset/26679-drawer-in-place-navigation.md b/.changeset/26679-drawer-in-place-navigation.md
new file mode 100644
index 00000000000..d220d2c9de5
--- /dev/null
+++ b/.changeset/26679-drawer-in-place-navigation.md
@@ -0,0 +1,25 @@
+---
+'@object-ui/components': patch
+'@object-ui/app-shell': patch
+'@object-ui/plugin-detail': patch
+'@object-ui/plugin-list': patch
+'@object-ui/react': patch
+'@object-ui/i18n': patch
+---
+
+feat(app-shell,components): the record drawer opens a related row or a lookup in place, one level at a time, and its bar names no object
+
+Clause-②: no
+Metadata-protocol: none
+
+The console's record drawer is one drawer (objectstack#26679):
+
+- **The bar.** Its left side is empty at the root: no object name and no "n / N" position. Its right side holds `↑` `↓` (previous / next through the level's sibling set, disabled at the ends; `↑` / `↓` and `k` / `j` while no text field, menu or grid has the keyboard), `⤢` (icon only) and `✕`. `NavigationOverlay` gains three optional props for it — `onBack`, `trail` and `stepper` — and keeps its title as the accessible name only, on every host.
+- **The root steps through the list.** `ListView` reports the rows it shows, in their order, through a new `onRowsChange` host prop (beside `onTotalChange`, answered by a paged grid; a board or a grouped grid reports no rows). The object page hands them to its drawer, so `↑` `↓` at the root move to the previous / next row, disabled at the first and the last.
+- **In place.** A related row, a lookup value or any record link inside the drawer opens that record in the same drawer and keeps the previous one on a stack. The bar shows `←` and the path (each ancestor returns to its level); `←` or `Esc` goes back one level, `Esc` at the root closes, `✕` closes the whole stack. `↑` `↓` one level down step through the rows the parent's related list showed, never across levels. Each level keeps its own `?tab=`. An object that declares `navigation.mode: 'page'` still opens its page.
+- **⤢ at any level** opens that record's page with the levels below it as its `?from=` trail, so its back link reads the record it was opened from; the page offers "Collapse to drawer", which returns to the drawer with the stack restored.
+- **On the record page**, a related row or a lookup opens its record in a drawer over the page, under the same rules.
+- **`data-table` inside a dialog.** A table drawn inside a dialog now opens its rows: the row click ignored every click with a dialog ancestor, so a related list inside a record drawer drew pointer rows that opened nothing. Only a dialog portalled out of the row (one that does not contain it) still stops the click.
+- **What a reload keeps.** The root stays `?recordId=` on an object list; the levels above it live in memory, so a reload shows the root record.
+
+Published-type additions, all optional: `RelatedRecordHandlers.onView` receives the ids of the rows on show as a third argument, and `RelatedList`'s `onRowClick` receives those rows as its third argument. The i18n packs gain `detail.collapseToDrawer` in all ten languages. In zh, the `⤢` label (`detail.openAsFullPage` and `console.objectView.expandToPage`, kept byte-identical) reads 整页打开.
diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx
index 63928c3ccdf..31650fd93fc 100644
--- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx
+++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx
@@ -23,6 +23,11 @@
* The object list handed to the view carries the invoice's and the account's
* definitions, as the console's metadata catalog does: the record page links
* only to an object it can route to.
+ *
+ * Since objectstack#26679 the record page is a record drawer host, so "opens
+ * the account" means: in a drawer over the request page (the account declares
+ * no `navigation.mode`, so it opens the drawer, maintainer ruling 5 on that
+ * card), the router unmoved, the link's href still the account's record path.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, within, fireEvent } from '@testing-library/react';
@@ -207,7 +212,9 @@ const host = {
findOne: vi.fn(async (resource: string, id: string) =>
resource === 'invoice'
? { id, name: TARGET_TITLE, amount: 12500, account_id: { id: ACCOUNT_ID, name: ACCOUNT_TITLE } }
- : null),
+ : resource === 'account' && id === ACCOUNT_ID
+ ? { id, name: ACCOUNT_TITLE }
+ : null),
create: vi.fn(), update: vi.fn(), delete: vi.fn(),
getObjectSchema: vi.fn(async (name: string) => DEFS[name] ?? null),
};
@@ -310,7 +317,13 @@ describe('the served request page: Enter on the target card lookup link (objectu
expect(document.activeElement).toBe(link);
await user.keyboard('{Enter}');
- await waitFor(() => expect(screen.getByTestId('router-pathname').textContent).toBe(accountPath));
+ // The account opens in a drawer over the request page …
+ const drawer = await screen.findByRole('dialog', undefined, { timeout: 8000 });
+ await waitFor(() => expect(within(drawer).getAllByText(ACCOUNT_TITLE).length).toBeGreaterThan(0), { timeout: 8000 });
+ expect(host.findOne.mock.calls.some(([resource, id]) => resource === 'account' && id === ACCOUNT_ID)).toBe(true);
+ // … the router did not move, and the link still addresses the account's page.
+ expect(screen.getByTestId('router-pathname').textContent).toBe(`/apps/${APP}/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`);
+ expect(new URL(link.getAttribute('href') as string, 'http://test.local').pathname).toBe(accountPath);
expect(writeText).not.toHaveBeenCalled();
});
diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx
index dc9d8f2b994..0d6ead67188 100644
--- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx
+++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx
@@ -25,9 +25,15 @@
*
* Links are counted by the pathname their href resolves to, so the pins hold
* whatever query string the record page's builder adds (its `?from=` trail).
+ *
+ * Since objectstack#26679 the record page is a record drawer host: a plain
+ * click on the target opens it in a drawer over the request page (the invoice
+ * declares no `navigation.mode`, so it opens the drawer, maintainer ruling 5 on
+ * that card), while the href stays the record path for a new tab or a copied
+ * link, and a modified click is left to the browser.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
-import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
+import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
vi.mock('@object-ui/auth', async (importOriginal) => ({
@@ -284,15 +290,36 @@ describe('the served request page links a readable target to its record (objectu
expect(screen.getByRole('link', { name: TARGET_TITLE })).toBe(links[0]);
});
- it('readable: a plain click navigates in-app to the record path', async () => {
+ it('readable: a plain click opens the record in a drawer over the page, and the page stays put', async () => {
const id = 'Rd4bl3RecordQQQQ';
targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 });
const { container } = openServedPage({ record_id: id, status: 'pending', record_title: TARGET_TITLE });
await settledCard(container);
+ const requestPath = `/apps/${APP}/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`;
const notPrevented = fireEvent.click(screen.getByRole('link', { name: TARGET_TITLE }), { button: 0 });
expect(notPrevented).toBe(false);
- await waitFor(() => expect(screen.getByTestId('router-pathname').textContent).toBe(recordPath(id)));
+ // The drawer opens with the target record in it …
+ const drawer = await screen.findByRole('dialog', undefined, { timeout: 8000 });
+ await waitFor(() => expect(within(drawer).getAllByText(TARGET_TITLE).length).toBeGreaterThan(0), { timeout: 8000 });
+ expect(host.findOne.mock.calls.some(([resource, rid]) => resource === 'invoice' && rid === id)).toBe(true);
+ // … over the request page: the router did not move.
+ expect(screen.getByTestId('router-pathname').textContent).toBe(requestPath);
+ // The href still names the record path, for a new tab or a copied link.
+ expect(linksTo(recordPath(id))).toHaveLength(1);
+ });
+
+ it('readable: a modified click is the browser\'s — not intercepted, no drawer', async () => {
+ const id = 'Rd4bl3RecordQQQQ';
+ targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 });
+ const { container } = openServedPage({ record_id: id, status: 'pending', record_title: TARGET_TITLE });
+ await settledCard(container);
+
+ const link = screen.getByRole('link', { name: TARGET_TITLE });
+ const notPrevented = fireEvent.click(link, { button: 0, metaKey: true });
+ expect(notPrevented).toBe(true);
+ expect(new URL(link.getAttribute('href') as string, 'http://test.local').pathname).toBe(recordPath(id));
+ expect(screen.queryByRole('dialog')).toBeNull();
});
it('CONTROL: refused (the target read rejects 403) draws no link to the target anywhere', async () => {
diff --git a/ui/content/docs/guide/user-state-persistence.md b/ui/content/docs/guide/user-state-persistence.md
index 22279dcc330..e48049beb3f 100644
--- a/ui/content/docs/guide/user-state-persistence.md
+++ b/ui/content/docs/guide/user-state-persistence.md
@@ -323,3 +323,24 @@ calendar, gantt, list, gallery, map and timeline all mount `@object-ui/plugin-de
`RecordDetailPanel`, so a record reads with its declared labels and typed
values, and without its `hidden` fields, whichever view it was opened from
(objectui#13458).
+
+## The record drawer's stack
+
+A record opened from a list sits at the root of the drawer, and the root is
+**addressable**: `?recordId=` on the list's URL, so a reload, a shared link or
+Back reopens it. Records opened **inside** the drawer — a related row, a lookup
+value — open in the same drawer one level down (objectstack#26679), and those
+levels are **in-memory** state: a reload shows the root record.
+
+| State | Lives in | Survives a reload |
+|---|---|---|
+| The root record | `?recordId=` | Yes |
+| The levels above it, their titles and sibling sets | Component state | No |
+| Each level's record tab | `?tab=`, saved per level while another level is shown | The shown level's only |
+| The stack across a `⤢` round trip | Router history state, consumed once on return | No |
+
+`⤢` at any level opens that record's page with the levels below it as its
+`?from=` trail, which is addressable like any record trail. The page it opens
+offers "Collapse to drawer", which returns to the drawer with the stack it left;
+the history state that carries the stack is rewritten without it as soon as the
+drawer has it back, so a later reload or Back shows the root alone.
diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx
index 43072da53b2..9d49e515032 100644
--- a/ui/packages/app-shell/src/views/ObjectView.tsx
+++ b/ui/packages/app-shell/src/views/ObjectView.tsx
@@ -721,7 +721,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
// an object that declares `page` (or `new_window` / `none`) keeps its
// declaration.
const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference();
- const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({
+ const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({
activeView,
objectDef,
objects,
@@ -815,6 +815,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
filterScope,
actions,
handleRowClick,
+ handleListRowsChange,
navOverlay,
recordOpen,
openAsPage,
@@ -1165,9 +1166,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
);
- return (
+ const pageTree = (
{/* On a print page the shared chrome joins the print chrome row below,
so the row's buttons are not drawn under it. */}
@@ -3888,14 +3889,25 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
>
- {originFrom?.pathname && originFrom?.label && (
-
-
- {originFrom.label}
-
+ {/* The way back: the origin link, and on a page a drawer's ⤢
+ opened, the way back into that drawer (objectstack#26679).
+ Neither is drawn inside a drawer — the drawer's own bar is
+ its way back, and the page's history state is not its. */}
+ {!embedded && (
+
)}
{/* objectui#12906 — the print page's screen-only chrome. A page
that declares `print` holds only the printable component
@@ -4139,4 +4151,29 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
/>
);
+ // A drawer draws this view embedded and is its own host. A page is the host
+ // of the drawer its related rows and lookups open over it
+ // (objectstack#26679): the same in-place stack, drawn by this same view.
+ if (embedded) return pageTree;
+ return (
+ (
+
+ )}
+ >
+ {pageTree}
+
+ );
}
diff --git a/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
index 39902d7a1e8..ea83a387bb0 100644
--- a/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
+++ b/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
@@ -15,7 +15,12 @@
* create/edit form pages, or the per-object lifecycle affordances. This bridge
* (mounted inside the page's `ActionProvider`) closes that gap:
*
- * - 查看详情 → navigate to the child record's detail route
+ * - 查看详情 → under a record drawer host (objectstack#26679), open the
+ * child record in that drawer, one level down, with the rows
+ * its list showed as the level's ↑ ↓ set — unless the child
+ * object declares another way of opening; elsewhere, navigate
+ * to the child record's detail route. A lookup value takes
+ * the same door (`openRecord`).
* - 增 / 改 → open the child form as an OVERLAY on the parent detail
* (objectstack-ai/objectstack#2604 D3: a child task's return target is ALWAYS the parent
* detail with the subtable refreshed — never a route, which
@@ -91,6 +96,7 @@ import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RE
import { documentHref } from './documentHref.js';
import { exactRecordListFilter, recordListPath } from './useOpenRecordList.js';
import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js';
+import { opensInRecordDrawer, useRecordDrawer } from './recordDrawerContext.js';
/**
* Notify open related lists for `objectName` to refetch.
@@ -383,16 +389,32 @@ export function RelatedRecordActionsBridge({
[recordPath, routerRootHref],
);
+ /** The record drawer host this bridge sits under, if any (objectstack#26679). */
+ const drawer = useRecordDrawer();
+
/**
- * SPA-navigate to the destination {@link recordHref} addresses — by its
- * router path, because `navigate` applies the basename itself.
+ * Open the record {@link recordHref} addresses. Under a record drawer host
+ * — the list's drawer, a record drawn in it, or a record page — a record
+ * whose object opens in the drawer opens there, one level down
+ * (objectstack#26679), with `siblings` as its ↑ ↓ set and this bridge's
+ * record as the level being left. Otherwise SPA-navigate by its router
+ * path, because `navigate` applies the basename itself. Either way only a
+ * routable target opens, as before: the drawer's ⤢ goes to that route.
*/
const openRecord = useCallback(
- (objectName: string, recordId: string | number) => {
+ (objectName: string, recordId: string | number, siblings?: ReadonlyArray) => {
const path = recordPath(objectName, recordId);
- if (path) navigate(path);
+ if (!path) return;
+ if (drawer && opensInRecordDrawer(objects.find((o: any) => o?.name === objectName))) {
+ drawer.open(objectName, recordId, {
+ ...(siblings ? { siblings } : {}),
+ ...(parentTitle ? { fromTitle: parentTitle } : {}),
+ });
+ return;
+ }
+ navigate(path);
},
- [recordPath, navigate],
+ [recordPath, navigate, drawer, objects, parentTitle],
);
// objectstack-ai/objectstack#2604 D3 — open a child create/edit task as the console's global record
@@ -550,7 +572,7 @@ export function RelatedRecordActionsBridge({
const handlers: RelatedRecordHandlers = {
// Viewing a child record is always allowed when the list is visible.
// Same route the lookup links address — `recordPath` above.
- onView: (id) => openRecord(objectName, id),
+ onView: (id, _record, siblings) => openRecord(objectName, id, siblings),
};
/**
@@ -653,7 +675,7 @@ export function RelatedRecordActionsBridge({
return handlers;
},
recordHref,
- openRecord,
+ openRecord: (objectName: string, recordId: string | number) => openRecord(objectName, recordId),
}),
// `parentRecord` / `parentObjectFields` / `predicateScope` join the deps for
// the #4646 create predicates: a parent record that changes (a save, a
diff --git a/ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx
new file mode 100644
index 00000000000..6d9ea0f42ed
--- /dev/null
+++ b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx
@@ -0,0 +1,339 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26679 — the record drawer opens a related row or a lookup in
+ * place, one level down, and goes back one level at a time; ⤢ carries the
+ * path into the page and 「Collapse to drawer」 brings the stack back.
+ *
+ * Every case mounts the real `RelatedRecordActionsBridge` the record body
+ * mounts, under the real drawer stack, with a probe standing in for the
+ * related list and the lookup (both reach the bridge through the published
+ * `RelatedRecordActionsContext`, which is the door the real renderers take).
+ */
+
+import * as React from 'react';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from 'react-router-dom';
+import '@testing-library/jest-dom';
+import { useRelatedRecordActions } from '@object-ui/react';
+
+import { RelatedRecordActionsBridge } from '../RelatedRecordActionsBridge';
+import {
+ RecordDrawerReturnLink,
+ RecordPageDrawerHost,
+ RecordStackDrawer,
+ expandedRecordPath,
+ useRecordDrawerStack,
+ type RecordDrawerLevel,
+} from '../recordDrawerStack';
+
+afterEach(() => cleanup());
+
+const APP = 'hotcrm';
+const OBJECTS = [
+ { name: 'crm_lead', label: 'Lead' },
+ { name: 'crm_task', label: 'Task' },
+ { name: 'sys_user', label: 'User' },
+ { name: 'crm_contract', label: 'Contract', navigation: { mode: 'page' } },
+];
+const TITLES: Record = { 'L-1': 'Mira Costa', 'T-1': 'Send quote', 'T-2': 'Call back', 'T-3': 'Demo' };
+const TASKS = ['T-1', 'T-2', 'T-3'];
+
+/** Where the router is: path, search and the history state's keys. */
+function RouterLocation() {
+ const location = useLocation();
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/** A record body: its bridge, a related list of tasks, two lookups, a tab strip. */
+function RecordBody({ level }: { level: { objectName: string; recordId: string } }) {
+ return (
+
+
{`${level.objectName}/${level.recordId}`}
+
+
+ );
+}
+
+function Probe() {
+ const actions = useRelatedRecordActions();
+ const [, setSearchParams] = useSearchParams();
+ const tasks = actions?.resolve({ objectName: 'crm_task', relationshipField: 'lead', parentId: 'L-1' });
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+/** The object list's host shape: a root the URL controls, closed by the host. */
+function ListHost({ onClose, onExpand }: { onClose: () => void; onExpand: (path: RecordDrawerLevel[]) => void }) {
+ const root = React.useMemo(() => ({ objectName: 'crm_lead', recordId: 'L-1' }), []);
+ const stack = useRecordDrawerStack({ root });
+ return (
+ }
+ />
+ );
+}
+
+function mountList(initial = `/apps/${APP}/crm_lead?recordId=L-1`) {
+ const onClose = vi.fn();
+ const onExpand = vi.fn();
+ render(
+
+
+ } />
+ } />
+
+
+ ,
+ );
+ return { onClose, onExpand };
+}
+
+const body = () => screen.getByTestId('record-body').textContent;
+const trail = () => screen.queryByTestId('record-drawer-trail')?.textContent ?? null;
+const where = () => screen.getByTestId('router-location').textContent ?? '';
+const button = (name: string) => screen.getByRole('button', { name });
+
+describe('a related row opens in the same drawer, one level down (ruling 5)', () => {
+ it('pushes the task, shows ← and the path, and ← returns to the lead', () => {
+ mountList();
+ expect(body()).toBe('crm_lead/L-1');
+ expect(screen.getByTestId('record-drawer-leading').textContent).toBe('');
+
+ fireEvent.click(button('open task row'));
+ expect(body()).toBe('crm_task/T-1');
+ // Still one drawer, and the router did not move.
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(where()).toBe(`/apps/${APP}/crm_lead?recordId=L-1`);
+ expect(trail()).toBe('Lead Mira CostaTask');
+
+ fireEvent.click(button('Go back'));
+ expect(body()).toBe('crm_lead/L-1');
+ expect(trail()).toBeNull();
+ });
+
+ it('a lookup value takes the same door; each ancestor in the path returns to its level', () => {
+ mountList();
+ fireEvent.click(button('open task row'));
+ fireEvent.click(button('open owner lookup'));
+ expect(body()).toBe('sys_user/U-1');
+ expect(trail()).toBe('Lead Mira CostaTask Send quoteUser');
+ fireEvent.click(screen.getByRole('button', { name: /Lead Mira Costa/ }));
+ expect(body()).toBe('crm_lead/L-1');
+ });
+
+ it('Esc goes back one level; Esc at the root closes', () => {
+ const { onClose } = mountList();
+ fireEvent.click(button('open task row'));
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expect(body()).toBe('crm_lead/L-1');
+ expect(onClose).not.toHaveBeenCalled();
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('control: an object that declares `page` still navigates, and the drawer stays put', () => {
+ mountList();
+ fireEvent.click(button('open contract lookup'));
+ expect(where().split('?')[0]).toBe(`/apps/${APP}/crm_contract/record/K-1`);
+ });
+});
+
+describe('↑ ↓ move through the level\'s sibling set, never across levels', () => {
+ it('steps through the related rows the list showed, disabled at the ends', () => {
+ mountList();
+ fireEvent.click(button('open task row'));
+ expect((button('Previous record') as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.keyDown(document.body, { key: 'ArrowDown' });
+ expect(body()).toBe('crm_task/T-2');
+ fireEvent.keyDown(document.body, { key: 'j' });
+ expect(body()).toBe('crm_task/T-3');
+ expect((button('Next record') as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.click(button('Previous record'));
+ expect(body()).toBe('crm_task/T-2');
+ // Still one level down: ← returns to the lead, not to T-1.
+ fireEvent.click(button('Go back'));
+ expect(body()).toBe('crm_lead/L-1');
+ });
+
+ it('a lookup has no sibling set: no ↑ ↓ are drawn for it', () => {
+ mountList();
+ fireEvent.click(button('open owner lookup'));
+ expect(screen.queryByRole('button', { name: 'Next record' })).toBeNull();
+ });
+});
+
+describe('each level keeps its own ?tab=', () => {
+ it('a new level opens on its default tab, and ← brings the left tab back', () => {
+ mountList();
+ fireEvent.click(button('show related tab'));
+ expect(where()).toContain('tab=related');
+ fireEvent.click(button('open task row'));
+ expect(where()).not.toContain('tab=');
+ fireEvent.click(button('Go back'));
+ expect(where()).toContain('tab=related');
+ });
+});
+
+describe('⤢ carries the path into the page', () => {
+ it('hands the host the path from the root to the record shown', () => {
+ const { onExpand } = mountList();
+ fireEvent.click(button('open task row'));
+ fireEvent.click(button('Open as full page'));
+ const [path] = onExpand.mock.calls[0];
+ expect(path.map((l: RecordDrawerLevel) => `${l.objectName}/${l.recordId}`)).toEqual([
+ 'crm_lead/L-1',
+ 'crm_task/T-1',
+ ]);
+ expect(path[0].title).toBe('Mira Costa');
+ });
+
+ it('the page route names the ancestors as its ?from= trail', () => {
+ const to = expandedRecordPath(APP, OBJECTS, [
+ { objectName: 'crm_lead', recordId: 'L-1', title: 'Mira Costa' },
+ { objectName: 'crm_task', recordId: 'T-1' },
+ ]);
+ const [path, query] = (to ?? '').split('?');
+ expect(path).toBe(`/apps/${APP}/crm_task/record/T-1`);
+ expect(JSON.parse(new URLSearchParams(query).get('from') ?? '[]')).toEqual([
+ { o: 'crm_lead', i: 'L-1', t: 'Mira Costa' },
+ ]);
+ // The root has no ancestors in the drawer: no trail.
+ expect(expandedRecordPath(APP, OBJECTS, [{ objectName: 'crm_lead', recordId: 'L-1' }])).toBe(
+ `/apps/${APP}/crm_lead/record/L-1`,
+ );
+ });
+});
+
+/** A record page: the page host, its body, and the 「Collapse to drawer」 link. */
+function RecordPage() {
+ const location = useLocation();
+ const [, objectName, , recordId] = location.pathname.split('/').slice(2);
+ const level = { objectName, recordId };
+ return (
+ }
+ >
+
+
+
+
+
+ );
+}
+
+function mountPage(initial: string) {
+ render(
+
+
+ } />
+
+
+ ,
+ );
+}
+
+const dialogBody = () => screen.getByRole('dialog').querySelector('[data-testid="record-body"]')?.textContent;
+
+describe('on the record page, a related row opens a drawer over the page (target 4)', () => {
+ it('opens the task over the lead page, steps its siblings at the root, and ✕ closes it', () => {
+ mountPage(`/apps/${APP}/crm_lead/record/L-1`);
+ expect(screen.queryByRole('dialog')).toBeNull();
+ fireEvent.click(screen.getAllByRole('button', { name: 'open task row' })[0]);
+ expect(dialogBody()).toBe('crm_task/T-1');
+ expect(where()).toBe(`/apps/${APP}/crm_lead/record/L-1`);
+ // The drawer's root is the task: nothing on the left, ↑ ↓ over the rows.
+ expect(screen.getByTestId('record-drawer-leading').textContent).toBe('');
+ fireEvent.click(button('Next record'));
+ expect(dialogBody()).toBe('crm_task/T-2');
+ fireEvent.click(screen.getByTitle('Close'));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('the page keeps its ?tab= while the drawer is open', () => {
+ mountPage(`/apps/${APP}/crm_lead/record/L-1?tab=related`);
+ fireEvent.click(screen.getAllByRole('button', { name: 'open task row' })[0]);
+ expect(where()).not.toContain('tab=');
+ fireEvent.click(screen.getByTitle('Close'));
+ expect(where()).toContain('tab=related');
+ });
+
+ it('⤢ opens the page with the path, and 「Collapse to drawer」 restores the stack', async () => {
+ mountPage(`/apps/${APP}/crm_lead/record/L-1`);
+ fireEvent.click(screen.getAllByRole('button', { name: 'open task row' })[0]);
+ // One more level: the task's owner.
+ // The page beneath is hidden from the accessibility tree while the drawer
+ // is open, so this is the drawer's own lookup.
+ fireEvent.click(button('open owner lookup'));
+ expect(dialogBody()).toBe('sys_user/U-1');
+ fireEvent.click(button('Open as full page'));
+
+ const [path, query] = where().split('?');
+ expect(path).toBe(`/apps/${APP}/sys_user/record/U-1`);
+ expect(JSON.parse(new URLSearchParams(query).get('from') ?? '[]')).toEqual([
+ { o: 'crm_lead', i: 'L-1', t: 'Mira Costa' },
+ { o: 'crm_task', i: 'T-1', t: 'Send quote' },
+ ]);
+ expect(screen.queryByRole('dialog')).toBeNull();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('record-drawer-return'));
+ });
+ expect(where()).toBe(`/apps/${APP}/crm_lead/record/L-1`);
+ expect(dialogBody()).toBe('sys_user/U-1');
+ expect(trail()).toBe('Task Send quoteUser');
+ // The restore is consumed: the history entry no longer carries it.
+ expect(screen.getByTestId('router-state').textContent).not.toContain('recordDrawerRestore');
+ fireEvent.click(button('Go back'));
+ expect(dialogBody()).toBe('crm_task/T-1');
+ });
+});
diff --git a/ui/packages/app-shell/src/views/__tests__/recordDrawerStack.rootStepper-26679.test.tsx b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack.rootStepper-26679.test.tsx
new file mode 100644
index 00000000000..46e6585e69b
--- /dev/null
+++ b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack.rootStepper-26679.test.tsx
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26679 — `↑` `↓` at the ROOT of the record drawer (ruling 3: the
+ * controls stay, no counter anywhere).
+ *
+ * The root level steps through the sibling set its host hands it — on the
+ * object list, the rows `ListView` reports through `onRowsChange` — exactly as
+ * a nested level steps through its parent's related rows: present when the
+ * host knows the set, absent when it passes none, disabled (never wrapped) at
+ * the first and the last row, and a step hands the new root to the host.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import '@testing-library/jest-dom';
+
+import { RecordStackDrawer, useRecordDrawerStack } from '../recordDrawerStack';
+
+afterEach(() => cleanup());
+
+const OBJECTS = [{ name: 'crm_lead', label: 'Lead' }];
+const ROWS = ['L-1', 'L-2', 'L-3'];
+
+/** The list host's shape: the root is host state, stepped by `onRootStep`. */
+function ListHost({ start, siblings }: { start: string; siblings?: readonly string[] }) {
+ const [recordId, setRecordId] = React.useState(start);
+ const root = React.useMemo(
+ () => ({ objectName: 'crm_lead', recordId, ...(siblings ? { siblings } : {}) }),
+ [recordId, siblings],
+ );
+ const stack = useRecordDrawerStack({ root, onRootStep: setRecordId });
+ return (
+ {}}
+ renderRecord={(level) =>
{level.recordId}
}
+ />
+ );
+}
+
+function mount(start: string, siblings?: readonly string[]) {
+ render(
+
+
+ ,
+ );
+}
+
+const body = () => screen.getByTestId('record-body').textContent;
+const previous = () => screen.queryByRole('button', { name: 'Previous record' }) as HTMLButtonElement | null;
+const next = () => screen.queryByRole('button', { name: 'Next record' }) as HTMLButtonElement | null;
+
+describe('the drawer root steps through the host\'s rows (objectstack#26679)', () => {
+ it('draws ↑ ↓ when the host knows the set, and a step moves the root', () => {
+ mount('L-2', ROWS);
+ expect(previous()).not.toBeNull();
+ expect(next()).not.toBeNull();
+ fireEvent.click(next()!);
+ expect(body()).toBe('L-3');
+ fireEvent.keyDown(document.body, { key: 'k' });
+ fireEvent.keyDown(document.body, { key: 'ArrowUp' });
+ expect(body()).toBe('L-1');
+ // Still the root: nothing on the left of the bar.
+ expect(screen.getByTestId('record-drawer-leading').textContent).toBe('');
+ });
+
+ it('↑ at the first row and ↓ at the last are disabled, never wrapped', () => {
+ mount('L-1', ROWS);
+ expect(previous()!.disabled).toBe(true);
+ fireEvent.keyDown(document.body, { key: 'ArrowUp' });
+ expect(body()).toBe('L-1');
+ fireEvent.click(next()!);
+ fireEvent.click(next()!);
+ expect(body()).toBe('L-3');
+ expect(next()!.disabled).toBe(true);
+ fireEvent.keyDown(document.body, { key: 'j' });
+ expect(body()).toBe('L-3');
+ });
+
+ it('control: no ↑ ↓ when the host passes no set', () => {
+ mount('L-2');
+ expect(previous()).toBeNull();
+ expect(next()).toBeNull();
+ });
+
+ it('control: no ↑ ↓ when the root is not in the set the host passed', () => {
+ mount('L-9', ROWS);
+ expect(previous()).toBeNull();
+ expect(next()).toBeNull();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx
index 784a14c9544..b7355bc6886 100644
--- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx
+++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx
@@ -70,6 +70,7 @@ export function useObjectViewListRenderer({
filterScope,
actions,
handleRowClick,
+ handleListRowsChange,
navOverlay,
recordOpen,
openAsPage,
@@ -103,6 +104,8 @@ export function useObjectViewListRenderer({
filterScope: FilterTokenScope;
actions: ReturnType;
handleRowClick: ReturnType['handleRowClick'];
+ /** objectstack#26679 — the rows the list shows, for the drawer's root `↑` `↓`. */
+ handleListRowsChange: ReturnType['handleListRowsChange'];
navOverlay: ReturnType['navOverlay'];
/** objectstack#26681 — the Display menu's Open records section. */
recordOpen: NonNullable;
@@ -870,6 +873,7 @@ export function useObjectViewListRenderer({
onRowClick={(record: any, event?: any) => {
handleRowClick(record, event);
}}
+ onRowsChange={handleListRowsChange}
onSortChange={(sort: any) => {
// objectui#12418 — this user's sort, not the view's.
persistListSort(sort);
@@ -928,7 +932,7 @@ export function useObjectViewListRenderer({
dataSource={ds}
/>
);
- }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker, recordOpen, openAsPage]);
+ }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange]);
return { renderListView };
}
diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts
index a71a02fbcc8..70cbe7d0ae5 100644
--- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts
+++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts
@@ -4,9 +4,15 @@
* `?recordId=` URL state, "expand to full page", and the record links the
* list publishes, moved out of `ObjectViewInner` verbatim (objectstack#25992,
* slice 6). Every input is passed in; the component reads the returned values.
+ *
+ * The drawer is a stack since objectstack#26679 (`./recordDrawerStack.tsx`):
+ * `?recordId=` addresses its ROOT — the list row — and the levels a related
+ * row or a lookup opens above it live in memory, so a reload shows the root.
+ * `⤢` at any level opens that record's page, and the page's 「Collapse to
+ * drawer」 returns here with the stack it left (`drawerRestore`).
*/
-import { useMemo, useCallback, useEffect } from 'react';
+import { useMemo, useCallback, useEffect, useState } from 'react';
import type { NavigateFunction, Location, SetURLSearchParams } from 'react-router-dom';
import { overlayWidthFor } from '@object-ui/plugin-view';
import { useNavigationOverlay } from '@object-ui/react';
@@ -14,6 +20,12 @@ import type { ViewNavigationConfig } from '@object-ui/types';
import { RECORD_DRAWER_PARAM } from '../urlParams.js';
import { viewTabLabel } from './viewTabLabel.js';
import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js';
+import {
+ expandedRecordPath,
+ recordDrawerReturnState,
+ useTakenRestore,
+ type RecordDrawerLevel,
+} from './recordDrawerStack.js';
import type { useServedViewItems } from '../hooks/useServedViewItems.js';
import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import type { buildViewTabs } from './objectViewTabs.js';
@@ -90,7 +102,7 @@ export function useObjectViewRecordNavigation({
const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM);
// Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick
const handleNavOverlayNavigate = useCallback(
- (recordId: string | number, action?: string) => {
+ (recordId: string | number, action?: string, extraState?: Record) => {
if (action === 'new_window') {
// Open record detail in a new browser tab with Console-correct URL.
// Same builder the list's link column publishes as its href
@@ -113,6 +125,7 @@ export function useObjectViewRecordNavigation({
// already treated the name and the token identically, which is
// exactly what made the substitution inaudible on this surface.
const originState = {
+ ...extraState,
from: {
pathname: location.pathname + (location.search || ''),
label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language),
@@ -151,7 +164,10 @@ export function useObjectViewRecordNavigation({
objectName: objectDef.name,
onNavigate: handleNavOverlayNavigate,
});
+ // A ⤢ return's stack, taken out of the history state once (objectstack#26679).
+ const [drawerRestore, clearDrawerRestore] = useTakenRestore();
const handleDrawerClose = () => {
+ clearDrawerRestore();
navOverlay.close();
const newParams = new URLSearchParams(searchParams);
newParams.delete(RECORD_DRAWER_PARAM);
@@ -192,17 +208,71 @@ export function useObjectViewRecordNavigation({
[navOverlay, searchParams, setSearchParams]
);
/**
- * "Expand to full page" — invoked from the drawer header chevron. Closes
- * the drawer (which clears `?recordId=…`) and router-pushes to the
- * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page.
+ * "Expand to full page" (`⤢`) at any level of the drawer's stack
+ * (objectstack#26679): closes the drawer (which clears `?recordId=…`) and
+ * router-pushes to the record's page. The root keeps its list-relative
+ * route and the list as its origin; a level above it opens
+ * `/apps/:app/:object/record/:id` with the levels below it as its `?from=`
+ * trail. Either way the page is handed the way back to this drawer
+ * (`recordDrawerReturnState`). Mirrors Linear/Notion peek-to-page.
*/
- const handleExpandDrawer = useCallback(() => {
- const rec = navOverlay.selectedRecord as Record | null;
- const id = rec && ((rec as any).id ?? (rec as any)._id);
- if (id == null) return;
+ const handleExpandDrawer = useCallback((path?: RecordDrawerLevel[]) => {
+ const levels: RecordDrawerLevel[] = path && path.length > 0
+ ? path
+ : drawerRecordId ? [{ objectName: objectDef.name, recordId: drawerRecordId }] : [];
+ const target = levels[levels.length - 1];
+ if (!target) return;
+ const returnState = recordDrawerReturnState(
+ { pathname: location.pathname, search: location.search, state: location.state },
+ levels,
+ );
+ if (levels.length === 1) {
+ handleDrawerClose();
+ handleNavOverlayNavigate(target.recordId, undefined, returnState);
+ return;
+ }
+ const to = expandedRecordPath(appName, objects, levels);
+ if (to === null) return;
handleDrawerClose();
- handleNavOverlayNavigate(id as string | number);
- }, [navOverlay.selectedRecord, handleNavOverlayNavigate]);
+ navigate(to, { state: returnState });
+ }, [drawerRecordId, objectDef.name, location.pathname, location.search, location.state, handleNavOverlayNavigate, appName, objects, navigate, handleDrawerClose]);
+ /**
+ * The rows the list shows, in its order (`ListView`'s `onRowsChange`,
+ * objectstack#26679): the root level's sibling set, which the drawer's
+ * `↑` `↓` step through. Held as the ids' joined key so an equal report
+ * changes nothing.
+ */
+ const [listRowIds, setListRowIds] = useState<{ key: string; ids: readonly string[] }>({ key: '', ids: [] });
+ const handleListRowsChange = useCallback((rows: ReadonlyArray>) => {
+ const ids = rows
+ .map((row) => (row?.id ?? row?._id) as string | number | undefined)
+ .filter((id): id is string | number => id != null && id !== '')
+ .map(String);
+ const key = JSON.stringify(ids);
+ setListRowIds((prev) => (prev.key === key ? prev : { key, ids }));
+ }, []);
+ /** `↑` `↓` at the root: the sibling becomes the row `?recordId=` names. */
+ const handleRootStep = useCallback((recordId: string) => {
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ next.set(RECORD_DRAWER_PARAM, recordId);
+ return next;
+ }, { replace: true });
+ }, [setSearchParams]);
+ /**
+ * The drawer's root — the list row `?recordId=` names — or `null`, with the
+ * list's shown rows as its sibling set when the list reported any.
+ */
+ const drawerRoot = useMemo(
+ () => (drawerRecordId
+ ? {
+ objectName: objectDef.name,
+ recordId: drawerRecordId,
+ ...(listRowIds.ids.length > 0 ? { siblings: listRowIds.ids } : {}),
+ }
+ : null),
+ [drawerRecordId, objectDef.name, listRowIds],
+ );
// Sync URL-based recordId to overlay state
useEffect(() => {
if (drawerRecordId && !navOverlay.isOpen) {
@@ -211,5 +281,5 @@ export function useObjectViewRecordNavigation({
navOverlay.close();
}
}, [drawerRecordId]);
- return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer };
+ return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep };
}
diff --git a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx
index b09358bfb49..f376ec5ec11 100644
--- a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx
+++ b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx
@@ -6,57 +6,69 @@
* prefers the record beside the list (`utils/recordOpenPreference.ts`,
* objectstack#26677) — stays in the component, where it wraps the list
* itself. Every value is passed in as a prop.
+ *
+ * Since objectstack#26679 the drawer is the in-place stack of
+ * `./recordDrawerStack.tsx`: its root is the list row `?recordId=` names, a
+ * related row or a lookup inside it opens one level down, and its bar names
+ * no object (maintainer ruling 2 on that card).
*/
-import { NavigationOverlay } from '@object-ui/components';
-import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
+import type { useObjectTranslation } from '@object-ui/i18n';
import { RecordDetailView } from './RecordDetailView.js';
+import { RecordStackDrawer, useRecordDrawerStack } from './recordDrawerStack.js';
import type { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js';
import type { ConsoleObjectViewProps } from './ObjectView.js';
+type RecordNavigation = ReturnType;
+
export function ObjectViewRecordOverlay({
navOverlay,
handleDrawerClose,
- objectLabel,
objectDef,
handleExpandDrawer,
+ drawerRoot,
+ drawerRestore,
+ handleRootStep,
t,
dataSource,
objects,
onEdit,
}: {
- navOverlay: ReturnType['navOverlay'];
- handleDrawerClose: ReturnType['handleDrawerClose'];
- objectLabel: ReturnType['objectLabel'];
+ navOverlay: RecordNavigation['navOverlay'];
+ handleDrawerClose: RecordNavigation['handleDrawerClose'];
objectDef: ConsoleObjectViewProps['objects'][number];
- handleExpandDrawer: ReturnType['handleExpandDrawer'];
+ handleExpandDrawer: RecordNavigation['handleExpandDrawer'];
+ drawerRoot: RecordNavigation['drawerRoot'];
+ drawerRestore: RecordNavigation['drawerRestore'];
+ handleRootStep: RecordNavigation['handleRootStep'];
t: ReturnType['t'];
dataSource: ConsoleObjectViewProps['dataSource'];
objects: ConsoleObjectViewProps['objects'];
onEdit: ConsoleObjectViewProps['onEdit'];
}) {
+ // The root steps through the rows the list shows (objectstack#26679).
+ const stack = useRecordDrawerStack({ root: drawerRoot, restoredPath: drawerRestore, onRootStep: handleRootStep });
return (
- { if (!open) handleDrawerClose(); }}
- title={objectLabel(objectDef)}
+
- {(record: Record) => {
- const recordId = (record.id || record._id) as string;
- return (
-
- );
- }}
-
+ objects={objects}
+ renderRecord={(level) => (
+
+ )}
+ />
);
}
diff --git a/ui/packages/app-shell/src/views/recordDrawerContext.ts b/ui/packages/app-shell/src/views/recordDrawerContext.ts
new file mode 100644
index 00000000000..df77a6d88d3
--- /dev/null
+++ b/ui/packages/app-shell/src/views/recordDrawerContext.ts
@@ -0,0 +1,50 @@
+/**
+ * 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 drawer host's context (objectstack#26679): what a record link
+ * under a drawer host — a related row, a lookup value — calls to open a record
+ * in that drawer, one level down. The host and its stack live in
+ * `./recordDrawerStack.tsx`; this module holds only what
+ * `RelatedRecordActionsBridge` reads, so the bridge and the drawer do not
+ * import each other.
+ */
+
+import { createContext, useContext } from 'react';
+
+/** What a record link inside a drawer host calls to open a record in place. */
+export interface RecordDrawerApi {
+ open(
+ objectName: string,
+ recordId: string | number,
+ options?: {
+ /** The ids of the rows shown beside the record, in their order. */
+ siblings?: ReadonlyArray;
+ /** The title of the record the link sits on — the level being left. */
+ fromTitle?: string;
+ },
+ ): void;
+}
+
+export const RecordDrawerContext = createContext(null);
+
+/** The nearest record drawer host, or `null` where records open as pages. */
+export function useRecordDrawer(): RecordDrawerApi | null {
+ return useContext(RecordDrawerContext);
+}
+
+/**
+ * Whether a record of this object opens in the drawer: its object's own way
+ * of opening, the drawer unless it declares another (objectstack#26677 — the
+ * spec's `NavigationModeSchema` defaults to `drawer`; `page`, `new_window` and
+ * `none` keep the record page).
+ */
+export function opensInRecordDrawer(objectDef: unknown): boolean {
+ const mode = (objectDef as { navigation?: { mode?: unknown } } | null | undefined)?.navigation?.mode;
+ return mode == null || mode === 'drawer';
+}
diff --git a/ui/packages/app-shell/src/views/recordDrawerStack.tsx b/ui/packages/app-shell/src/views/recordDrawerStack.tsx
new file mode 100644
index 00000000000..8558cb88d2f
--- /dev/null
+++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx
@@ -0,0 +1,566 @@
+/**
+ * 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 drawer's in-place stack (objectstack#26679, maintainer rulings
+ * 2-5 on that card).
+ *
+ * A record drawer is ONE drawer. A related row, a lookup value or any other
+ * record link inside it opens that record in the same drawer, one level down,
+ * and the previous record stays on a stack: the bar's left side shows `←` and
+ * the path (each ancestor returns to its level), `←` / `Esc` go back one
+ * level, `Esc` at the root closes and `✕` closes the whole stack. `↑` `↓`
+ * step through the current level's sibling set — the rows its list showed
+ * beside it — and never cross a level. `⤢` at any level opens that record's
+ * page with the ancestors as its `?from=` trail, and the page's 「Collapse to
+ * drawer」 control returns to the drawer with the stack restored.
+ *
+ * **What survives a reload.** Only the root is addressable: on an object list
+ * it is `?recordId=`, which a reload reopens. The levels above it live in
+ * component memory (and, across the ⤢ round trip, in the router's history
+ * state, consumed once on return), so a reload shows the root record.
+ *
+ * **`?tab=`.** Every level reads the one record-tab URL parameter, so a level
+ * that is left keeps the tab it showed and gets it back when the stack returns
+ * to it; a newly opened level starts on its page's default tab.
+ *
+ * Two hosts mount it: the object list (`objectViewRecordOverlay.tsx`, the root
+ * is the list row the URL names) and the record page itself
+ * ({@link RecordPageDrawerHost}, mounted by `RecordDetailView` when it is not
+ * embedded: a related row or lookup on the page opens over it). Whether a
+ * record opens here at all is its object's own way of opening — the drawer by
+ * default, the page where the object declares `navigation.mode: 'page'`
+ * (`opensInRecordDrawer`, `./recordDrawerContext.ts`).
+ */
+
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { ChevronRight, Minimize2 } from 'lucide-react';
+import { NavigationOverlay, type NavigationOverlayMode } from '@object-ui/components';
+import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
+import {
+ RECORD_DETAIL_TAB_PARAM,
+ RECORD_TRAIL_PARAM,
+ appendRecordTrail,
+ encodeRecordTrail,
+ type RecordTrailEntry,
+} from '../urlParams.js';
+import { appRecordPath, routableObjectNames } from './RelatedRecordActionsBridge.js';
+import { RecordDrawerContext, type RecordDrawerApi } from './recordDrawerContext.js';
+
+export { opensInRecordDrawer, useRecordDrawer, type RecordDrawerApi } from './recordDrawerContext.js';
+
+/** One level of the stack: a record, and what the drawer learned about it. */
+export interface RecordDrawerLevel {
+ objectName: string;
+ recordId: string;
+ /** The ids of the level's sibling set, in the order its list showed them. */
+ siblings?: readonly string[];
+ /** The record's title, learned when a level above it was opened from it. */
+ title?: string;
+ /** The `?tab=` the level showed when a level above it was opened. */
+ tab?: string | null;
+}
+
+/** The history-state key the ⤢ round trip carries the stack under. */
+const RETURN_STATE_KEY = 'recordDrawerReturn';
+const RESTORE_STATE_KEY = 'recordDrawerRestore';
+
+/** Where the record page's 「Collapse to drawer」 goes, written by ⤢. */
+interface RecordDrawerReturn {
+ pathname: string;
+ search: string;
+ state: unknown;
+ path: RecordDrawerLevel[];
+}
+
+function isLevel(value: unknown): value is RecordDrawerLevel {
+ const v = value as RecordDrawerLevel | null | undefined;
+ return !!v && typeof v.objectName === 'string' && v.objectName !== ''
+ && typeof v.recordId === 'string' && v.recordId !== '';
+}
+
+/** The stack a page's ⤢ return hands back to a host, or `undefined`. */
+export function readRestoredPath(state: unknown): RecordDrawerLevel[] | undefined {
+ const path = (state as Record | null | undefined)?.[RESTORE_STATE_KEY];
+ return Array.isArray(path) && path.length > 0 && path.every(isLevel) ? path : undefined;
+}
+
+function readReturn(state: unknown): RecordDrawerReturn | undefined {
+ const value = (state as Record | null | undefined)?.[RETURN_STATE_KEY] as
+ | RecordDrawerReturn
+ | undefined;
+ if (!value || typeof value.pathname !== 'string' || !Array.isArray(value.path)) return undefined;
+ return value.path.length > 0 && value.path.every(isLevel) ? value : undefined;
+}
+
+/** History state without the restore key — the restore is consumed once. */
+function withoutRestore(state: unknown): unknown {
+ if (!state || typeof state !== 'object' || !(RESTORE_STATE_KEY in state)) return state;
+ const { [RESTORE_STATE_KEY]: _consumed, ...rest } = state as Record;
+ return Object.keys(rest).length > 0 ? rest : null;
+}
+
+const levelKey = (level: { objectName: string; recordId: string } | null | undefined) =>
+ level ? `${level.objectName}:${level.recordId}` : null;
+
+/**
+ * The `?tab=` reader and writer the stack saves and restores levels through.
+ * Written with `replace` (switching levels stacks no history entries) and
+ * with the entry's own history state, which the record page's back link
+ * reads.
+ */
+function useRecordTabParam() {
+ const [, setSearchParams] = useSearchParams();
+ const location = useLocation();
+ // The router's location, read at call time: a handler outlives the render
+ // it was made in.
+ const locationRef = useRef(location);
+ locationRef.current = location;
+ const read = useCallback(
+ () => new URLSearchParams(locationRef.current.search).get(RECORD_DETAIL_TAB_PARAM),
+ [],
+ );
+ const write = useCallback(
+ (tab: string | null | undefined) => {
+ const sp = new URLSearchParams(locationRef.current.search);
+ if ((sp.get(RECORD_DETAIL_TAB_PARAM) ?? null) === (tab ?? null)) return;
+ if (tab) sp.set(RECORD_DETAIL_TAB_PARAM, tab);
+ else sp.delete(RECORD_DETAIL_TAB_PARAM);
+ setSearchParams(sp, { replace: true, state: locationRef.current.state });
+ },
+ [setSearchParams],
+ );
+ return { read, write };
+}
+
+/**
+ * The stack over a host-controlled root (level 0). The levels reset whenever
+ * the root changes — another row, a step at the root, a close — except to the
+ * path a ⤢ return restores for that same root.
+ */
+export function useRecordDrawerStack({
+ root,
+ restoredPath,
+ onRootStep,
+}: {
+ root: RecordDrawerLevel | null;
+ restoredPath?: RecordDrawerLevel[];
+ onRootStep?: (recordId: string) => void;
+}) {
+ const rootKey = levelKey(root);
+ const restores = (candidate: RecordDrawerLevel[] | undefined) =>
+ !!candidate && levelKey(candidate[0]) === rootKey;
+ const [state, setState] = useState<{
+ rootKey: string | null;
+ restore: RecordDrawerLevel[] | undefined;
+ path: RecordDrawerLevel[];
+ }>(() => ({ rootKey, restore: restoredPath, path: restores(restoredPath) ? restoredPath! : [] }));
+ let stored = state.path;
+ if (state.rootKey !== rootKey || state.restore !== restoredPath) {
+ // A new root starts a new stack; a restore handed over for THIS root
+ // replaces it. Compared by the restore's own value — the host keeps it in
+ // state until it closes — never by a memoised identity.
+ if (restores(restoredPath) && (state.restore !== restoredPath || state.rootKey !== rootKey)) {
+ stored = restoredPath!;
+ } else if (state.rootKey !== rootKey) {
+ stored = [];
+ }
+ setState({ rootKey, restore: restoredPath, path: stored });
+ }
+ // Level 0 is the host's root, annotated with what the stack learned on it.
+ const path: RecordDrawerLevel[] = useMemo(
+ () => (root ? [{ ...root, title: stored[0]?.title, tab: stored[0]?.tab }, ...stored.slice(1)] : []),
+ [root, stored],
+ );
+ const depth = path.length - 1;
+ const current = root ? path[depth] : null;
+ const tabParam = useRecordTabParam();
+
+ const push = useCallback(
+ (objectName, recordId, options) => {
+ if (!root) return;
+ const leftTab = tabParam.read();
+ setState((prev) => {
+ const base = prev.path.length > 0 ? prev.path : [root];
+ const top = base[base.length - 1];
+ if (top.objectName === objectName && top.recordId === String(recordId)) return prev;
+ const annotated = [...base];
+ annotated[annotated.length - 1] = {
+ ...top,
+ tab: leftTab,
+ ...(options?.fromTitle ? { title: options.fromTitle } : {}),
+ };
+ annotated.push({
+ objectName,
+ recordId: String(recordId),
+ ...(options?.siblings ? { siblings: options.siblings.map(String) } : {}),
+ });
+ return { ...prev, path: annotated };
+ });
+ // The new level opens on its own page's default tab.
+ tabParam.write(null);
+ },
+ [root, tabParam],
+ );
+
+ /** Return to level `target` (0 = the root), restoring the tab it showed. */
+ const popTo = useCallback(
+ (target: number) => {
+ if (target < 0 || target >= depth) return;
+ tabParam.write(path[target].tab ?? null);
+ setState((prev) => ({ ...prev, path: prev.path.slice(0, target + 1) }));
+ },
+ [depth, path, tabParam],
+ );
+
+ /** The current level's previous / next sibling, when it has a set. */
+ const stepper = useMemo(() => {
+ if (!current?.siblings || current.siblings.length === 0) return undefined;
+ if (depth === 0 && !onRootStep) return undefined;
+ const index = current.siblings.indexOf(current.recordId);
+ if (index < 0) return undefined;
+ const go = (recordId: string) => () => {
+ if (depth === 0) {
+ onRootStep?.(recordId);
+ return;
+ }
+ setState((prev) => {
+ const next = [...prev.path];
+ const top = next[next.length - 1];
+ next[next.length - 1] = { objectName: top.objectName, recordId, siblings: top.siblings };
+ return { ...prev, path: next };
+ });
+ };
+ return {
+ onPrevious: index > 0 ? go(current.siblings[index - 1]) : undefined,
+ onNext: index < current.siblings.length - 1 ? go(current.siblings[index + 1]) : undefined,
+ };
+ }, [current, depth, onRootStep]);
+
+ return { path, depth, current, push, popTo, stepper, tabParam };
+}
+
+export type RecordDrawerStack = ReturnType;
+
+/**
+ * The drawer itself: the shared `NavigationOverlay` with the stack's bar —
+ * `←` and the path on the left, `↑` `↓` / `⤢` / `✕` on the right — drawing
+ * the current level through the host's `renderRecord`, inside the context
+ * that makes every record link below it open one level down.
+ */
+export function RecordStackDrawer({
+ stack,
+ open,
+ mode = 'drawer',
+ width,
+ storageKey,
+ objects,
+ expandLabel,
+ onClose,
+ onExpand,
+ renderRecord,
+}: {
+ stack: RecordDrawerStack;
+ open: boolean;
+ mode?: NavigationOverlayMode;
+ width?: string | number;
+ storageKey?: string;
+ objects: ReadonlyArray;
+ expandLabel?: string;
+ onClose: () => void;
+ /** ⤢ — the path from the root to the record shown, inclusive. */
+ onExpand?: (path: RecordDrawerLevel[]) => void;
+ renderRecord: (level: RecordDrawerLevel) => ReactNode;
+}) {
+ const { objectLabel } = useObjectLabel();
+ const { path, depth, current, push, popTo, stepper } = stack;
+ const api = useMemo(() => ({ open: push }), [push]);
+ const labelOf = (objectName: string) => {
+ const def = objects.find((o) => (o as { name?: unknown } | null)?.name === objectName);
+ return def ? objectLabel(def as never) : objectName;
+ };
+ const trail =
+ depth > 0 ? (
+
+ ) : undefined;
+ return (
+ {
+ if (!next) onClose();
+ }}
+ onExpand={onExpand && current ? () => onExpand(path) : undefined}
+ expandLabel={expandLabel}
+ storageKey={storageKey}
+ onBack={depth > 0 ? () => popTo(depth - 1) : undefined}
+ trail={trail}
+ stepper={stepper}
+ >
+ {() => (
+
+ {/* Keyed by level and object: a step to a sibling swaps the record
+ in place (the view follows `recordIdOverride`), while a level
+ change is a different record page. */}
+
+ {current ? renderRecord(current) : null}
+
+
+ )}
+
+ );
+}
+
+/**
+ * The page a ⤢ opens: the record's route, with the levels below it as the
+ * `?from=` trail its back link walks, after `leading` (the trail the host
+ * itself sits under, if any). `null` when the console cannot route there.
+ */
+export function expandedRecordPath(
+ appName: string | undefined,
+ objects: ReadonlyArray,
+ path: RecordDrawerLevel[],
+ leadingTrail?: string | null,
+): string | null {
+ const target = path[path.length - 1];
+ if (!target) return null;
+ const url = appRecordPath(appName, routableObjectNames(objects), target.objectName, target.recordId);
+ if (url === null) return null;
+ let trail = leadingTrail ?? null;
+ for (const level of path.slice(0, -1)) {
+ const entry: RecordTrailEntry = { o: level.objectName, i: level.recordId, ...(level.title ? { t: level.title } : {}) };
+ trail = appendRecordTrail(trail, entry);
+ }
+ if (!trail || encodeRecordTrail([]) === trail) return url;
+ const sp = new URLSearchParams();
+ sp.set(RECORD_TRAIL_PARAM, trail);
+ return `${url}?${sp.toString()}`;
+}
+
+/**
+ * The history state a ⤢ writes on the page it opens: everything the page's
+ * 「Collapse to drawer」 needs to return to this host with the same stack.
+ */
+export function recordDrawerReturnState(
+ from: { pathname: string; search: string; state: unknown },
+ path: RecordDrawerLevel[],
+): Record {
+ const back: RecordDrawerReturn = {
+ pathname: from.pathname,
+ search: from.search,
+ state: withoutRestore(from.state),
+ path,
+ };
+ return { [RETURN_STATE_KEY]: back };
+}
+
+/**
+ * Take a ⤢ return's restored stack out of the history state, once: it is
+ * handed to the host as state of its own, and the history entry is rewritten
+ * without it, so a later reload or Back shows the root alone. The host clears
+ * it when its drawer closes, so a later open of the same root starts fresh.
+ */
+export function useTakenRestore(): [RecordDrawerLevel[] | undefined, () => void] {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [taken, setTaken] = useState();
+ const restored = readRestoredPath(location.state);
+ useEffect(() => {
+ if (!restored) return;
+ setTaken(restored);
+ navigate(
+ { pathname: location.pathname, search: location.search, hash: location.hash },
+ { replace: true, state: withoutRestore(location.state) },
+ );
+ }, [restored, location, navigate]);
+ const clear = useCallback(() => setTaken(undefined), []);
+ return [taken, clear];
+}
+
+/**
+ * The record page's 「Collapse to drawer」 (objectstack#26679): drawn on a page a
+ * drawer's ⤢ opened, it returns to that drawer's host with the stack it left.
+ */
+export function RecordDrawerReturnLink() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { t } = useObjectTranslation();
+ const back = readReturn(location.state);
+ if (!back) return null;
+ const label = t('detail.collapseToDrawer', { defaultValue: 'Collapse to drawer' });
+ const restoreState = {
+ ...((back.state && typeof back.state === 'object' ? back.state : {}) as Record),
+ [RESTORE_STATE_KEY]: back.path,
+ };
+ return (
+
+ );
+}
+
+/**
+ * The record page as a drawer host (objectstack#26679 target 4): a related
+ * row or a lookup value on the page opens its record in a drawer over the
+ * page, under the same rules as the list's drawer; an object that declares
+ * `page` still navigates (the bridge asks {@link opensInRecordDrawer}).
+ *
+ * The page's own `?tab=` is set aside while the drawer is open and restored
+ * when it closes.
+ */
+export function RecordPageDrawerHost({
+ appName,
+ objects,
+ pageObjectName,
+ pageRecordId,
+ pageTitle,
+ renderRecord,
+ children,
+}: {
+ appName: string | undefined;
+ objects: ReadonlyArray;
+ pageObjectName: string | undefined;
+ pageRecordId: string | undefined;
+ pageTitle: string | undefined;
+ renderRecord: (level: RecordDrawerLevel) => ReactNode;
+ children: ReactNode;
+}) {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { t } = useObjectTranslation();
+ const [restored, clearRestored] = useTakenRestore();
+ const [root, setRoot] = useState(null);
+ // A ⤢ return reopens the drawer at the root it left.
+ useEffect(() => {
+ if (!restored) return;
+ const [first] = restored;
+ setRoot({ objectName: first.objectName, recordId: first.recordId, ...(first.siblings ? { siblings: first.siblings } : {}) });
+ }, [restored]);
+ const pageTab = useRef(null);
+ const onRootStep = useCallback(
+ (recordId: string) => setRoot((prev) => (prev ? { ...prev, recordId } : prev)),
+ [],
+ );
+ const stack = useRecordDrawerStack({ root, restoredPath: restored, onRootStep });
+ const { push, tabParam } = stack;
+
+ const openFromPage = useCallback(
+ (objectName, recordId, options) => {
+ if (root) {
+ push(objectName, recordId, options);
+ return;
+ }
+ pageTab.current = tabParam.read();
+ tabParam.write(null);
+ setRoot({
+ objectName,
+ recordId: String(recordId),
+ ...(options?.siblings ? { siblings: options.siblings.map(String) } : {}),
+ });
+ },
+ [root, push, tabParam],
+ );
+ const api = useMemo(() => ({ open: openFromPage }), [openFromPage]);
+
+ const close = useCallback(() => {
+ setRoot(null);
+ clearRestored();
+ tabParam.write(pageTab.current);
+ pageTab.current = null;
+ }, [tabParam, clearRestored]);
+
+ const expand = useCallback(
+ (path: RecordDrawerLevel[]) => {
+ // The page itself heads the trail: it is the record the drawer opened
+ // over, after the trail the page already sits under.
+ const pageTrail =
+ pageObjectName && pageRecordId
+ ? appendRecordTrail(new URLSearchParams(location.search).get(RECORD_TRAIL_PARAM), {
+ o: pageObjectName,
+ i: pageRecordId,
+ ...(pageTitle ? { t: pageTitle } : {}),
+ })
+ : null;
+ const to = expandedRecordPath(appName, objects, path, pageTrail);
+ if (!to) return;
+ // The way back returns to the page on the tab it showed.
+ const search = new URLSearchParams(location.search);
+ if (pageTab.current) search.set(RECORD_DETAIL_TAB_PARAM, pageTab.current);
+ else search.delete(RECORD_DETAIL_TAB_PARAM);
+ const searchText = search.toString();
+ // The drawer closes: the page it opens may be drawn by this same view.
+ setRoot(null);
+ clearRestored();
+ pageTab.current = null;
+ navigate(to, {
+ state: recordDrawerReturnState(
+ { pathname: location.pathname, search: searchText ? `?${searchText}` : '', state: location.state },
+ path,
+ ),
+ });
+ },
+ [appName, objects, pageObjectName, pageRecordId, pageTitle, location, navigate, clearRestored],
+ );
+
+ const rootDef = root ? objects.find((o) => (o as { name?: unknown } | null)?.name === root.objectName) : undefined;
+ const rootWidth = (rootDef as { navigation?: { width?: string | number } } | undefined)?.navigation?.width;
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx b/ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx
new file mode 100644
index 00000000000..cf379461a51
--- /dev/null
+++ b/ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * 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 drawer's bar (objectstack#26679, maintainer rulings 2-5).
+ *
+ * - The left side is empty unless the host hands it a way back: no object
+ * name and no position are drawn (the title is the accessible name only).
+ * - `onBack` draws `←` and takes `Esc`, so `Esc` goes back one level; without
+ * it `Esc` closes, as before.
+ * - `stepper` draws `↑` `↓`, each disabled at its end of the set, and the
+ * arrow keys and `k` / `j` step — never while a text field or a composite
+ * widget owns the keyboard, never with a modifier.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { NavigationOverlay, type NavigationOverlayProps } from '../custom/navigation-overlay';
+
+afterEach(() => cleanup());
+
+function renderBar(props: Partial = {}) {
+ const setIsOpen = vi.fn();
+ render(
+ {}}
+ setIsOpen={setIsOpen}
+ title="Lead"
+ {...props}
+ >
+ {() => (
+
+
+
+
+ )}
+ ,
+ );
+ return { setIsOpen };
+}
+
+const leading = () => screen.getByTestId('record-drawer-leading');
+
+describe('the drawer bar names no object and no position (rulings 2, 3)', () => {
+ it('draws nothing on the left at the root; the title stays an accessible name', () => {
+ renderBar({ onExpand: () => {} });
+ expect(leading().textContent).toBe('');
+ expect(leading().children).toHaveLength(0);
+ // Radix still names the dialog by the host's title.
+ expect(screen.getByRole('dialog', { name: 'Lead' })).toBeTruthy();
+ expect(screen.getByText('Lead').className).toContain('sr-only');
+ });
+
+ it('draws ← and the host trail when the host hands it a way back', () => {
+ const onBack = vi.fn();
+ renderBar({ onBack, trail: Lead Mira Costa › Task });
+ fireEvent.click(screen.getByRole('button', { name: 'Go back' }));
+ expect(onBack).toHaveBeenCalledTimes(1);
+ expect(leading().textContent).toContain('Lead Mira Costa › Task');
+ });
+});
+
+describe('Esc goes back one level, and closes only at the root (ruling 5)', () => {
+ it('with onBack: Esc pops and does not close', () => {
+ const onBack = vi.fn();
+ const { setIsOpen } = renderBar({ onBack });
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expect(onBack).toHaveBeenCalledTimes(1);
+ expect(setIsOpen).not.toHaveBeenCalledWith(false);
+ });
+
+ it('control, no onBack: Esc closes, as before', () => {
+ const { setIsOpen } = renderBar();
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expect(setIsOpen).toHaveBeenCalledWith(false);
+ });
+});
+
+describe('↑ ↓ step through the host set (ruling 3 keeps the controls)', () => {
+ it('draws the pair, disabled at the ends', () => {
+ renderBar({ stepper: { onNext: () => {} } });
+ expect((screen.getByRole('button', { name: 'Previous record' }) as HTMLButtonElement).disabled).toBe(true);
+ expect((screen.getByRole('button', { name: 'Next record' }) as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('control, no stepper: no pair is drawn', () => {
+ renderBar();
+ expect(screen.queryByRole('button', { name: 'Previous record' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Next record' })).toBeNull();
+ });
+
+ it('ArrowUp / k and ArrowDown / j step while nothing owns the keyboard', () => {
+ const onPrevious = vi.fn();
+ const onNext = vi.fn();
+ renderBar({ stepper: { onPrevious, onNext } });
+ fireEvent.keyDown(document.body, { key: 'ArrowDown' });
+ fireEvent.keyDown(document.body, { key: 'j' });
+ fireEvent.keyDown(document.body, { key: 'ArrowUp' });
+ fireEvent.keyDown(document.body, { key: 'k' });
+ expect(onNext).toHaveBeenCalledTimes(2);
+ expect(onPrevious).toHaveBeenCalledTimes(2);
+ });
+
+ it('never fires from a text field, a composite widget, or with a modifier', () => {
+ const onPrevious = vi.fn();
+ const onNext = vi.fn();
+ renderBar({ stepper: { onPrevious, onNext } });
+ fireEvent.keyDown(screen.getByLabelText('Body field'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByLabelText('Body field'), { key: 'j' });
+ fireEvent.keyDown(screen.getByLabelText('Body grid'), { key: 'ArrowUp' });
+ fireEvent.keyDown(document.body, { key: 'k', metaKey: true });
+ fireEvent.keyDown(document.body, { key: 'ArrowDown', shiftKey: true });
+ expect(onNext).not.toHaveBeenCalled();
+ expect(onPrevious).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx b/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx
index bc069e22998..db795980505 100644
--- a/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx
+++ b/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx
@@ -108,7 +108,7 @@ describe('NavigationOverlay expand button — default label (objectstack#5506)',
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.getByTitle('整页打开').getAttribute('aria-label')).toBe('整页打开');
expect(screen.queryByTitle('Open as full page')).toBeNull();
});
@@ -131,7 +131,7 @@ describe('NavigationOverlay expand button — default label (objectstack#5506)',
});
expect(screen.getByTitle('主机自定义标签')).toBeTruthy();
- expect(screen.queryByTitle('以完整页面打开')).toBeNull();
+ expect(screen.queryByTitle('整页打开')).toBeNull();
});
});
diff --git a/ui/packages/components/src/custom/navigation-overlay.tsx b/ui/packages/components/src/custom/navigation-overlay.tsx
index 4d19b040454..e25dc224d80 100644
--- a/ui/packages/components/src/custom/navigation-overlay.tsx
+++ b/ui/packages/components/src/custom/navigation-overlay.tsx
@@ -56,7 +56,7 @@
*/
import React from 'react';
-import { Maximize2, X } from 'lucide-react';
+import { ArrowLeft, ChevronDown, ChevronUp, Maximize2, X } from 'lucide-react';
import { cn } from '../lib/utils';
import {
Sheet,
@@ -94,9 +94,16 @@ const OVERLAY_DEFAULT_TRANSLATIONS: Record = {
// Default accessible name + tooltip of the icon-only expand button. Hosts
// may override it via the `expandLabel` prop; this is what ships otherwise.
'detail.openAsFullPage': 'Open as full page',
- // VISIBLE overlay heading when the host passes no `title` — not merely an
- // a11y name.
+ // The drawer's accessible title when the host passes no `title`. Since
+ // objectstack#26679 the drawer bar draws no title text at all (the record
+ // body names the record), so this is an accessible name only; the split
+ // face still draws it as its heading.
'detail.recordDetail': 'Record Detail',
+ // The bar's back control (`onBack`, objectstack#26679).
+ 'detail.goBack': 'Go back',
+ // The bar's previous / next pair (`stepper`, objectstack#26679).
+ 'detail.previousRecord': 'Previous record',
+ 'detail.nextRecord': 'Next record',
// sr-only Sheet description used when the host passes no `description`.
'detail.recordDetailOverlay': 'Record detail overlay for {{title}}.',
};
@@ -142,9 +149,16 @@ export interface NavigationOverlayProps {
isOverlay: boolean;
/** Target view/form name from NavigationConfig */
view?: string;
- /** Title for the overlay header */
+ /**
+ * The overlay's title. The split face draws it as its heading; the drawer
+ * keeps it as the accessible name only — its bar draws no title text
+ * (objectstack#26679, maintainer ruling 2).
+ */
title?: string;
- /** Description for the overlay header */
+ /**
+ * The overlay's description: drawn under the split face's heading, an
+ * accessible description only on the drawer (objectstack#26679).
+ */
description?: string;
/** CSS class for the overlay container */
className?: string;
@@ -211,8 +225,86 @@ export interface NavigationOverlayProps {
* prefix again at a call site.
*/
legacyStorageKey?: string;
+ /**
+ * One level back (drawer mode only, objectstack#26679). When provided the
+ * bar draws a `←` control at its left edge and `Esc` calls this instead of
+ * closing the drawer: a host that opened a record IN PLACE (a related row,
+ * a lookup value) hands it the pop of its stack, so each `←` / `Esc` undoes
+ * one click. Absent at the stack's root, where `Esc` closes as before.
+ */
+ onBack?: () => void;
+ /**
+ * The path drawn after the `←` control (drawer mode only,
+ * objectstack#26679) — the host's ancestors of the record shown, each one a
+ * control that returns to it. Nothing is drawn at the left of the bar when
+ * neither this nor {@link onBack} is passed: the bar names no object and no
+ * position (maintainer rulings 2 and 3 on objectstack#26679).
+ */
+ trail?: React.ReactNode;
+ /**
+ * Previous / next record (drawer mode only, objectstack#26679). When passed
+ * the bar draws the `↑` `↓` pair, and `↑` / `↓` / `k` / `j` step through the
+ * host's sibling set while no text input, menu or grid has the keyboard.
+ * A missing handler draws its control disabled — the ends of the set.
+ */
+ stepper?: {
+ onPrevious?: () => void;
+ onNext?: () => void;
+ };
}
+/**
+ * Whether a key event belongs to the element it was typed in rather than to
+ * the drawer's own shortcuts: a text field, an editable region, or a
+ * composite widget that already moves with the arrow keys (a menu, a list
+ * box, a grid, a tab strip).
+ */
+function keyEventOwnedByTarget(target: EventTarget | null): boolean {
+ if (!target || !(target instanceof Element)) return false;
+ if (target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) {
+ return true;
+ }
+ return !!target.closest(
+ '[role="menu"], [role="menubar"], [role="listbox"], [role="combobox"], [role="grid"], [role="treegrid"], [role="tree"], [role="tablist"], [role="radiogroup"], [role="slider"], [role="spinbutton"]',
+ );
+}
+
+/**
+ * The drawer's previous / next shortcuts (objectstack#26679): `ArrowUp` / `k`
+ * and `ArrowDown` / `j`, on the document while the drawer is open, never with
+ * a modifier and never while the event belongs to its target
+ * ({@link keyEventOwnedByTarget}).
+ */
+function useStepperKeys(
+ active: boolean,
+ onPrevious: (() => void) | undefined,
+ onNext: (() => void) | undefined,
+) {
+ const handlers = React.useRef({ onPrevious, onNext });
+ handlers.current = { onPrevious, onNext };
+ React.useEffect(() => {
+ if (!active || typeof document === 'undefined') return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+ if (keyEventOwnedByTarget(e.target)) return;
+ let step: (() => void) | undefined;
+ if (e.key === 'ArrowUp' || e.key === 'k') step = handlers.current.onPrevious;
+ else if (e.key === 'ArrowDown' || e.key === 'j') step = handlers.current.onNext;
+ else return;
+ // The key is the drawer's even at an end of the set, so the body does
+ // not scroll under a disabled control.
+ e.preventDefault();
+ step?.();
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [active]);
+}
+
+/** The bar's icon-button face, shared by every control on it. */
+const BAR_BUTTON_CLASS =
+ '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 disabled:pointer-events-none disabled:opacity-40';
+
/**
* The one `storageKey` spelling every record overlay uses, keyed by object.
*
@@ -374,6 +466,9 @@ export const NavigationOverlay: React.FC = ({
expandLabel,
storageKey,
legacyStorageKey,
+ onBack,
+ trail,
+ stepper,
}) => {
// Every chrome string below is locale-driven (objectstack#5430 for the two
// close affordances, objectstack#5506 for the rest). Must stay above the
@@ -395,6 +490,11 @@ export const NavigationOverlay: React.FC = ({
// Inline-edit dropdowns render in body-level poppers; without this guard the
// click that closes an open dropdown also dismisses the drawer (#2156).
const handleInteractOutside = usePopperAwareInteractOutside();
+ useStepperKeys(
+ mode === 'drawer' && isOpen && !!selectedRecord && !!stepper,
+ stepper?.onPrevious,
+ stepper?.onNext,
+ );
// Non-overlay modes don't render anything
if (mode === 'page' || mode === 'new_window' || mode === 'none') {
@@ -446,6 +546,9 @@ export const NavigationOverlay: React.FC = ({
// documented choice rather than dropping it is what "one shared
// overlay shell" has to mean when the two shells disagreed.
onOpenAutoFocus={(e) => e.preventDefault()}
+ // objectstack#26679 — below the root of an in-place stack, `Esc`
+ // goes back one level; only the root's `Esc` closes.
+ onEscapeKeyDown={onBack ? (e) => { e.preventDefault(); onBack(); } : undefined}
className={cn(
// Mobile: full width (no inline cap, no max-w from base sheet).
// sm+: honor the host-supplied width via `--ov-w` CSS var with a
@@ -476,32 +579,62 @@ export const NavigationOverlay: React.FC = ({
className="hidden sm:block absolute left-0 top-0 bottom-0 z-30 w-1 cursor-col-resize touch-none bg-transparent hover:bg-primary/40 active:bg-primary/60 transition-colors"
/>
)}
- {/* Chrome header — subdued breadcrumb-style label that does not
- compete with the record-title rendered by the embedded content.
- Buttons live in the same flex row so they share vertical
- centering with the title text (no absolute positioning, no
- pixel-perfect math to maintain). */}
+ {/* The bar (objectstack#26679, maintainer rulings 2-5): the left
+ side holds only the in-place path — `←` and the ancestors —
+ and is empty at the root; the right side holds ↑ ↓, ⤢ and ✕.
+ No object name and no position: the title and description stay
+ for assistive technology only (Radix requires the title). */}
-
+ {stepper && (
+ <>
+
+
+ >
+ )}
{onExpand && (
@@ -511,7 +644,7 @@ export const NavigationOverlay: React.FC = ({
type="button"
aria-label={t('common.close')}
title={t('common.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"
+ className={BAR_BUTTON_CLASS}
>
diff --git a/ui/packages/components/src/renderers/complex/__tests__/data-table-rowClickInDialog-26679.test.tsx b/ui/packages/components/src/renderers/complex/__tests__/data-table-rowClickInDialog-26679.test.tsx
new file mode 100644
index 00000000000..c4c7a80cfa6
--- /dev/null
+++ b/ui/packages/components/src/renderers/complex/__tests__/data-table-rowClickInDialog-26679.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectstack#26679 — a table drawn INSIDE a dialog opens its rows.
+ *
+ * The row's click heuristic ignores a click that comes from a dialog, so a
+ * Radix overlay portalled out of the row (a confirm, a popover) cannot open
+ * the record through React's virtual bubbling. It used to ignore every click
+ * whose target had a dialog ancestor at all — and the record drawer is a
+ * dialog, so a related list inside a drawer drew `cursor-pointer` rows that
+ * opened nothing. Only a dialog that does not contain the row is the click's
+ * source now.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import React from 'react';
+import { createPortal } from 'react-dom';
+import { ComponentRegistry } from '@object-ui/core';
+import '../data-table';
+
+const ROWS = [
+ { id: 'a', name: 'Ada' },
+ { id: 'b', name: 'Grace' },
+];
+
+function renderTable(insideDialog: boolean) {
+ const DataTable = ComponentRegistry.get('data-table') as any;
+ const onRowClick = vi.fn();
+ const table = (
+
+ createPortal(
+
: table);
+ return { onRowClick };
+}
+
+describe('data-table row click and dialogs (objectstack#26679)', () => {
+ it('a table inside a dialog opens the clicked row', () => {
+ const { onRowClick } = renderTable(true);
+ fireEvent.click(screen.getByText('Grace'));
+ expect(onRowClick).toHaveBeenCalledTimes(1);
+ expect(onRowClick.mock.calls[0][0]).toEqual(ROWS[1]);
+ });
+
+ it('control: a click from a dialog portalled out of the row still opens nothing', () => {
+ const { onRowClick } = renderTable(true);
+ fireEvent.click(screen.getByText('portalled b'));
+ expect(onRowClick).not.toHaveBeenCalled();
+ });
+
+ it('control: outside any dialog, the row opens and the portalled overlay does not', () => {
+ const { onRowClick } = renderTable(false);
+ fireEvent.click(screen.getByText('portalled a'));
+ expect(onRowClick).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('Ada'));
+ expect(onRowClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ui/packages/components/src/renderers/complex/data-table.tsx b/ui/packages/components/src/renderers/complex/data-table.tsx
index 8e14f45da0f..f0310a9b722 100644
--- a/ui/packages/components/src/renderers/complex/data-table.tsx
+++ b/ui/packages/components/src/renderers/complex/data-table.tsx
@@ -953,6 +953,17 @@ function isMaskedColumnKey(columns: readonly TableColumn[], accessorKey: string)
const STICKY_ROW_TINT =
'bg-[image:linear-gradient(var(--os-row-tint,transparent),var(--os-row-tint,transparent))]';
+/**
+ * Whether `target` sits in a `selector` element that does NOT contain `row`:
+ * an overlay portalled out of the row whose React events still bubble to it
+ * (objectstack#26679). A table drawn inside a dialog is inside that dialog
+ * too, so the dialog it sits in is not one the click came from.
+ */
+function portalledFrom(target: HTMLElement, row: Element, selector: string): boolean {
+ const overlay = target.closest(selector);
+ return !!overlay && !overlay.contains(row);
+}
+
/**
* objectui#13188 — whether the viewer has selected text that touches `el`.
* A drag that selects text ends in a `click` on the row or cell it started
@@ -3483,8 +3494,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
target.closest('[role="menuitem"]') ||
target.closest('[role="menuitemcheckbox"]') ||
target.closest('[role="menuitemradio"]') ||
- target.closest('[role="dialog"]') ||
- target.closest('[role="alertdialog"]') ||
+ // A dialog the click came FROM, portalled above
+ // this row — never the dialog the table itself
+ // sits in: a related list inside a record drawer
+ // opens its rows like any other (objectstack#26679).
+ portalledFrom(target, e.currentTarget, '[role="dialog"]') ||
+ portalledFrom(target, e.currentTarget, '[role="alertdialog"]') ||
target.closest('[role="listbox"]') ||
target.closest('[role="option"]') ||
target.closest('[role="tab"]') ||
diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts
index 78c176f8c4a..26d0d91b9eb 100644
--- a/ui/packages/i18n/src/locales/ar.ts
+++ b/ui/packages/i18n/src/locales/ar.ts
@@ -1236,6 +1236,7 @@ const ar = {
recordDetail: "تفاصيل السجل",
recordDetailWithLabel: "تفاصيل {{label}}",
openAsFullPage: "فتح كصفحة كاملة",
+ collapseToDrawer: "طي إلى اللوحة الجانبية",
recordDetailOverlay: "طبقة تفاصيل السجل الخاصة بـ {{title}}.",
addToFavorites: "إضافة إلى المفضلة",
removeFromFavorites: "إزالة من المفضلة",
diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts
index b3ce0ae5a3d..b5338c8b1c9 100644
--- a/ui/packages/i18n/src/locales/de.ts
+++ b/ui/packages/i18n/src/locales/de.ts
@@ -1110,6 +1110,7 @@ const de = {
recordDetail: "Datensatzdetails",
recordDetailWithLabel: "{{label}}-Details",
openAsFullPage: "Als ganze Seite öffnen",
+ collapseToDrawer: "In Seitenleiste verkleinern",
recordDetailOverlay: "Detail-Overlay des Datensatzes für {{title}}.",
addToFavorites: "Zu Favoriten hinzufügen",
removeFromFavorites: "Aus Favoriten entfernen",
diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts
index 471caa66034..961338764d4 100644
--- a/ui/packages/i18n/src/locales/en.ts
+++ b/ui/packages/i18n/src/locales/en.ts
@@ -1447,6 +1447,9 @@ const en = {
// a possessive particle (ja/zh) writes its own arrangement here.
recordDetailWithLabel: '{{label}} Detail',
openAsFullPage: 'Open as full page',
+ // The record page's way back into the drawer it was expanded from
+ // (objectstack#26679).
+ collapseToDrawer: 'Collapse to drawer',
recordDetailOverlay: 'Record detail overlay for {{title}}.',
addToFavorites: 'Add to favorites',
removeFromFavorites: 'Remove from favorites',
diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts
index 2ce0bfe0e91..87096612a30 100644
--- a/ui/packages/i18n/src/locales/es.ts
+++ b/ui/packages/i18n/src/locales/es.ts
@@ -1143,6 +1143,7 @@ const es = {
recordDetail: "Detalle del registro",
recordDetailWithLabel: "Detalle de {{label}}",
openAsFullPage: "Abrir como página completa",
+ collapseToDrawer: "Contraer en panel",
recordDetailOverlay: "Capa de detalle del registro para {{title}}.",
addToFavorites: "Añadir a favoritos",
removeFromFavorites: "Quitar de favoritos",
diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts
index 80ce043e483..657bac356bc 100644
--- a/ui/packages/i18n/src/locales/fr.ts
+++ b/ui/packages/i18n/src/locales/fr.ts
@@ -1140,6 +1140,7 @@ const fr = {
recordDetail: "Détail de l'enregistrement",
recordDetailWithLabel: "Détail — {{label}}",
openAsFullPage: "Ouvrir en pleine page",
+ collapseToDrawer: "Réduire en panneau",
recordDetailOverlay: "Panneau de détail de l'enregistrement pour {{title}}.",
addToFavorites: "Ajouter aux favoris",
removeFromFavorites: "Retirer des favoris",
diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts
index f9ac1f7f32e..9e323875e04 100644
--- a/ui/packages/i18n/src/locales/ja.ts
+++ b/ui/packages/i18n/src/locales/ja.ts
@@ -1110,6 +1110,7 @@ const ja = {
recordDetail: "レコード詳細",
recordDetailWithLabel: "{{label}}の詳細",
openAsFullPage: "フルページで開く",
+ collapseToDrawer: "ドロワーに戻す",
recordDetailOverlay: "{{title}} のレコード詳細オーバーレイ。",
addToFavorites: "お気に入りに追加",
removeFromFavorites: "お気に入りから削除",
diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts
index 55ffb353162..7de1e1bcbae 100644
--- a/ui/packages/i18n/src/locales/ko.ts
+++ b/ui/packages/i18n/src/locales/ko.ts
@@ -1110,6 +1110,7 @@ const ko = {
recordDetail: "레코드 세부 정보",
recordDetailWithLabel: "{{label}} 세부 정보",
openAsFullPage: "전체 페이지로 열기",
+ collapseToDrawer: "드로어로 접기",
recordDetailOverlay: "{{title}}의 레코드 세부 정보 오버레이입니다.",
addToFavorites: "즐겨찾기에 추가",
removeFromFavorites: "즐겨찾기에서 제거",
diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts
index 709c4981bde..2d1ca70e175 100644
--- a/ui/packages/i18n/src/locales/pt.ts
+++ b/ui/packages/i18n/src/locales/pt.ts
@@ -1140,6 +1140,7 @@ const pt = {
recordDetail: "Detalhe do registro",
recordDetailWithLabel: "Detalhe de {{label}}",
openAsFullPage: "Abrir como página inteira",
+ collapseToDrawer: "Recolher em painel",
recordDetailOverlay: "Sobreposição de detalhe do registro para {{title}}.",
addToFavorites: "Adicionar aos favoritos",
removeFromFavorites: "Remover dos favoritos",
diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts
index a972b5bf442..77356fd10ee 100644
--- a/ui/packages/i18n/src/locales/ru.ts
+++ b/ui/packages/i18n/src/locales/ru.ts
@@ -1177,6 +1177,7 @@ const ru = {
recordDetail: "Сведения о записи",
recordDetailWithLabel: "Сведения: {{label}}",
openAsFullPage: "Открыть на всю страницу",
+ collapseToDrawer: "Свернуть в панель",
recordDetailOverlay: "Наложение со сведениями о записи для {{title}}.",
addToFavorites: "Добавить в избранное",
removeFromFavorites: "Удалить из избранного",
diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts
index 996974c7887..1db7b9eaa4d 100644
--- a/ui/packages/i18n/src/locales/zh.ts
+++ b/ui/packages/i18n/src/locales/zh.ts
@@ -1184,7 +1184,8 @@ const zh = {
reactionCount_other: '{{emoji}} {{count}} 个回应',
recordDetail: '记录详情',
recordDetailWithLabel: '{{label}}详情',
- openAsFullPage: '以完整页面打开',
+ openAsFullPage: '整页打开',
+ collapseToDrawer: '收为抽屉',
recordDetailOverlay: '{{title}} 的记录详情浮层。',
addToFavorites: '添加到收藏',
removeFromFavorites: '从收藏中移除',
@@ -2230,7 +2231,7 @@ const zh = {
resetView: '重置视图',
resetViewTitle: '将此视图的排序、筛选和列宽恢复为视图自身的设置。只影响你自己。',
cannotDeleteMetaView: '该视图为内置视图,由代码定义,无法删除。',
- expandToPage: '以完整页面打开',
+ expandToPage: '整页打开',
openAsPage: '整页打开',
objectNotFound: '未找到对象',
objectNotFoundDescription: '对象"{{objectName}}"在当前配置中不存在。',
diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx
index bf33c62922f..869508af87a 100644
--- a/ui/packages/plugin-detail/src/RelatedList.tsx
+++ b/ui/packages/plugin-detail/src/RelatedList.tsx
@@ -147,8 +147,12 @@ export interface RelatedListProps {
* annotated it `React.MouseEvent` — which a narrower declaration refuses
* contravariantly. `BaseSchema`'s own `onClick` / `onChange` / `onSubmit`
* already use this spelling for exactly this situation.
+ *
+ * The THIRD parameter (objectstack#26679) is the rows the list shows at the
+ * moment of the click, in its order — the clicked row's siblings, which a
+ * host that opens the row in a drawer steps through with ↑ ↓.
*/
- onRowClick?: (row: any, event?: any) => void;
+ onRowClick?: (row: any, event?: any, rows?: readonly any[]) => void;
/**
* Child-object row actions (`locations` `list_item`, or `record_related`
* inside a parent record — objectui#11270), already localized by the host.
@@ -2347,6 +2351,13 @@ export const RelatedList: React.FC = ({
// column's edge. Display only — the read, filter and sort are unchanged.
const compact = React.useContext(RelatedListCompactContext);
+ // The row click hands the host the rows on show beside the clicked one
+ // (objectstack#26679), so its drawer can step through them.
+ const handleRowClick = React.useMemo(
+ () => (onRowClick ? (row: any, event?: any) => onRowClick(row, event, paginatedData) : undefined),
+ [onRowClick, paginatedData],
+ );
+
const viewSchema = React.useMemo(() => {
if (schema) return schema;
@@ -2373,7 +2384,7 @@ export const RelatedList: React.FC = ({
coverField: MOBILE_GALLERY_COVER_FIELD,
cardSize: 'medium',
},
- onRowClick,
+ onRowClick: handleRowClick,
};
}
@@ -2416,7 +2427,7 @@ export const RelatedList: React.FC = ({
onRowDelete: onRowDelete ? handleDeleteRow : undefined,
rowEditPredicates,
rowDeletePredicates,
- onRowClick,
+ onRowClick: handleRowClick,
// Child-object row actions (`list_item`, or `record_related` inside a
// parent record) rendered in the same overflow menu, dispatched with
// the clicked row as target.
@@ -2431,7 +2442,7 @@ export const RelatedList: React.FC = ({
default:
return { type: 'div', children: 'No view configured' };
}
- }, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, onRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]);
+ }, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, handleRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]);
const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined;
const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined;
diff --git a/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx
index 39277ebdb77..b33de03bee3 100644
--- a/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx
+++ b/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx
@@ -76,6 +76,11 @@ describe('RecordRelatedListRenderer — host CRUD + action wiring', () => {
p.onRowClick({ id: 'c1', name: 'Alice' });
expect(onView).toHaveBeenCalledWith('c1', { id: 'c1', name: 'Alice' });
+ // objectstack#26679 — the rows on show reach the host as the clicked
+ // row's sibling ids, in the list's order, for a drawer's ↑ ↓.
+ p.onRowClick({ id: 'c2', name: 'Bob' }, undefined, [{ id: 'c1' }, { _id: 'c2' }, { name: 'no id' }]);
+ expect(onView).toHaveBeenLastCalledWith('c2', { id: 'c2', name: 'Bob' }, ['c1', 'c2']);
+
p.onNew();
expect(onCreate).toHaveBeenCalledTimes(1);
diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx
new file mode 100644
index 00000000000..f2242e8ff83
--- /dev/null
+++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx
@@ -0,0 +1,94 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectstack#26679 — a related list's row click hands the host the rows the
+ * list shows, in its order, as a third argument: the clicked record's sibling
+ * set, which the record drawer steps through with ↑ ↓ one level down.
+ *
+ * The node `RelatedList` hands `SchemaRenderer` is captured (the pattern of
+ * `RelatedList.mobileLookupTitle-12188.test.tsx`), and its `onRowClick` is
+ * invoked the way the table invokes it — `(row, event)`.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render, waitFor } from '@testing-library/react';
+import * as React from 'react';
+import { RelatedList } from '../RelatedList';
+
+const h = vi.hoisted(() => ({ schema: null as any }));
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return {
+ ...actual,
+ SchemaRenderer: (props: any) => {
+ h.schema = props.schema;
+ return null;
+ },
+ };
+});
+
+const OBJECT = 'crm_task';
+const ROWS = [
+ { id: 't_1', subject: 'Send quote' },
+ { id: 't_2', subject: 'Call back' },
+ { id: 't_3', subject: 'Demo' },
+];
+const dataSource = {
+ getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: { subject: { type: 'text', label: 'Subject' } } })),
+ find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
+};
+
+afterEach(() => {
+ cleanup();
+ h.schema = null;
+});
+
+describe('RelatedList row click carries the rows on show (objectstack#26679)', () => {
+ it('calls the host with (row, event, rows), rows in the list order', async () => {
+ const onRowClick = vi.fn();
+ render(
+ ,
+ );
+ await waitFor(() => expect(h.schema?.data?.length ?? 0).toBe(3));
+ const event = { button: 0 };
+ h.schema.onRowClick(ROWS[1], event);
+ expect(onRowClick).toHaveBeenCalledTimes(1);
+ const [row, passedEvent, rows] = onRowClick.mock.calls[0];
+ expect(row).toBe(ROWS[1]);
+ expect(passedEvent).toBe(event);
+ expect(rows.map((r: any) => r.id)).toEqual(['t_1', 't_2', 't_3']);
+ });
+
+ it('control, no host handler: the node carries no row click', async () => {
+ render(
+ ,
+ );
+ await waitFor(() => expect(h.schema?.data?.length ?? 0).toBe(3));
+ expect(h.schema.onRowClick).toBeUndefined();
+ });
+});
diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx
index 28e315046c3..e2c8b45ce97 100644
--- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx
+++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx
@@ -715,12 +715,22 @@ const RecordRelatedListBody: React.FC = ({
// "View all": the child object's list page, scoped to this parent —
// see `onViewAll` above for when it is offered.
onViewAll={onViewAll}
- // Open the child record's detail page on row click (查看记录详情).
+ // Open the child record's detail page on row click (查看记录详情),
+ // with the ids of the rows on show beside it (objectstack#26679) so a
+ // host drawer can step through them.
onRowClick={
handlers?.onView
- ? (row: any) => {
+ ? (row: any, _event?: any, rows?: readonly any[]) => {
const id = rowId(row);
- if (id != null) handlers.onView!(id, row);
+ if (id == null) return;
+ if (!rows) {
+ handlers.onView!(id, row);
+ return;
+ }
+ const siblings = rows
+ .map(rowId)
+ .filter((sibling): sibling is string | number => sibling != null);
+ handlers.onView!(id, row, siblings);
}
: undefined
}
diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx
index 046f58fbebb..a654ba927fc 100644
--- a/ui/packages/plugin-list/src/ListView.tsx
+++ b/ui/packages/plugin-list/src/ListView.tsx
@@ -477,6 +477,18 @@ export interface ListViewProps {
* that draws the rows it fetched answers; other kinds never call it.
*/
onTotalChange?: (total: number) => void;
+ /**
+ * Called with the rows the list shows, in the order it shows them, each
+ * time that set changes once a load settles — another page, sort, filter or
+ * search (objectstack#26679). The console's object page hands it to its
+ * record drawer, whose `↑` `↓` at the root step through these rows. Answered
+ * where `onTotalChange` is: a paged grid that draws the rows it fetched; any
+ * other kind (a board, a calendar, a grouped grid) has no single row order
+ * and answers with no rows, so a host never steps through a stale set.
+ *
+ * A HOST prop, never a schema key: the rows are the list's runtime state.
+ */
+ onRowsChange?: (rows: ReadonlyArray>) => void;
/**
* The Display control's Open records section (objectstack#26681): how a row
* opens its record — the mode the host resolved for this object and view,
@@ -1543,6 +1555,7 @@ export const ListView = React.forwardRef(({
compact = false,
capped = false,
onTotalChange,
+ onRowsChange,
viewDefaults,
recordOpen,
...props
@@ -5472,6 +5485,25 @@ export const ListView = React.forwardRef(({
React.useEffect(() => {
if (settledTotal != null) onTotalChangeRef.current?.(settledTotal);
}, [settledTotal]);
+ // objectstack#26679 — the rows on show, for a host that steps through them.
+ // Keyed on the ids in their order (never on the identity of `data` or of
+ // the callback, AGENTS.md #10), so an equal refetch reports nothing new; a
+ // load in flight keeps the last answer, as the count does.
+ const onRowsChangeRef = React.useRef(onRowsChange);
+ React.useEffect(() => {
+ onRowsChangeRef.current = onRowsChange;
+ });
+ const rowSequenceShown = paginate && surfaceDrawsFetchedRows;
+ const shownRowsSettled = rowSequenceShown ? !loading && !loadError : true;
+ const shownRowsKey = rowSequenceShown
+ ? JSON.stringify(data.map((row: any) => row?.id ?? row?._id ?? null))
+ : '[]';
+ const shownRowsRef = React.useRef(data);
+ shownRowsRef.current = rowSequenceShown ? data : [];
+ React.useEffect(() => {
+ if (!shownRowsSettled) return;
+ onRowsChangeRef.current?.(shownRowsRef.current);
+ }, [shownRowsKey, shownRowsSettled]);
return (
+ ({
+ find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
+ findOne: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getObjectSchema: vi.fn(async (name: string) => (name === OBJECT ? objectDef : null)),
+ }) as any;
+
+function mount(schemaExtra: Record) {
+ const dataSource = makeDataSource();
+ const onRowsChange = vi.fn();
+ render(
+
+
+ ,
+ );
+ return { dataSource, onRowsChange };
+}
+
+const lastIds = (fn: ReturnType) =>
+ (fn.mock.calls.at(-1)?.[0] as Array<{ id: string }> | undefined)?.map((r) => r.id);
+
+afterEach(() => cleanup());
+
+describe('ListView onRowsChange (objectstack#26679)', () => {
+ it('a paged grid reports the rows it shows, in their shown order', async () => {
+ const { dataSource, onRowsChange } = mount({ viewType: 'grid' });
+ await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
+ await waitFor(() => expect(lastIds(onRowsChange)).toEqual(['l_3', 'l_1', 'l_2']));
+ });
+
+ it('control: a board has no single row order and reports no rows', async () => {
+ const { dataSource, onRowsChange } = mount({
+ viewType: 'kanban',
+ options: { kanban: { groupBy: 'status' } },
+ });
+ await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
+ await waitFor(() => expect(onRowsChange).toHaveBeenCalled());
+ expect(onRowsChange.mock.calls.every(([rows]) => rows.length === 0)).toBe(true);
+ });
+});
diff --git a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx
index 1990465b4a9..066dd2f6c15 100644
--- a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx
+++ b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx
@@ -63,8 +63,12 @@ export interface RelatedRowActionDef {
* optional: an omitted handler hides its affordance in the list.
*/
export interface RelatedRecordHandlers {
- /** Open the child record's detail page (查看详情). */
- onView?: (recordId: string | number, record?: unknown) => void;
+ /**
+ * Open the child record's detail page (查看详情). `siblings` are the ids of
+ * the rows the list shows beside it, in its order (objectstack#26679): a
+ * host that opens the record in a drawer steps through them with ↑ ↓.
+ */
+ onView?: (recordId: string | number, record?: unknown, siblings?: ReadonlyArray) => void;
/**
* Open a create form for a new child record. The host pre-links it to the
* parent (via the relationship field + parent id passed to `resolve`), so no
diff --git a/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts b/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts
index e625892d530..a5c58264f3e 100644
--- a/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts
+++ b/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts
@@ -219,7 +219,7 @@ function readSource(rel: string): string {
* the criterion.
*/
const HOOK_CALL = /useNavigationOverlay\s*\(/;
-const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; after?: string }> = [
+const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; after?: string; arity?: number }> = [
{ rel: 'packages/plugin-grid/src/ObjectGrid.tsx', member: 'onRowClick',
why: 'fed to useNavigationOverlay as its onRowClick', hop: HOOK_CALL },
{ rel: 'packages/plugin-kanban/src/ObjectKanban.tsx', member: 'onRowClick',
@@ -242,9 +242,13 @@ const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; a
why: 'the onRowClick arm of `props.onRowClick ?? props.onCardClick`', hop: HOOK_CALL },
{ rel: 'packages/plugin-list/src/ObjectGallery.tsx', member: 'onCardClick',
why: 'the onCardClick arm of the same `??`', hop: HOOK_CALL },
+ // Three since objectstack#26679: after the modifier payload, the rows the
+ // list shows (the drawer's sibling set). Optional like the second, so the
+ // payload this card pins is still declared and no caller is forced to pass
+ // either.
{ rel: 'packages/plugin-detail/src/RelatedList.tsx', member: 'onRowClick',
why: 'placed on the object-gallery schema it renders, reaching ObjectGallery props',
- hop: /type: 'object-gallery'/ },
+ hop: /type: 'object-gallery'/, arity: 3 },
// ⭐ objectui#7804's `objectql.ts` slice closed the remaining hole on this
// exact path. `RelatedList` above is IN *because* it writes `onRowClick` onto
// the `object-gallery` NODE — and until that slice the node type declared
@@ -367,12 +371,15 @@ describe('objectui#9357 — the arity counter, before it is pointed at the tree'
});
describe('objectui#9357 — consumers on the modifier-forwarding path declare the payload', () => {
- it.each(IN_SITES)('$rel declares two parameters for $member ($why)', ({ rel, member, after }) => {
+ it.each(IN_SITES)('$rel declares two parameters for $member ($why)', ({ rel, member, after, arity = 2 }) => {
const params = declParams(readSource(rel), member, after);
expect(params, `${rel} :: ${member} — no inline function-type declaration found`).not.toBeNull();
- expect(params, `${rel} :: ${member}`).toHaveLength(2);
- // The second parameter is optional, so no existing caller is forced to pass it.
- expect(params![1], `${rel} :: ${member} second parameter must stay optional`).toMatch(/^\w+\?:/);
+ expect(params, `${rel} :: ${member}`).toHaveLength(arity);
+ // The second parameter is optional, so no existing caller is forced to pass
+ // it — and so is any after it.
+ for (const param of params!.slice(1)) {
+ expect(param, `${rel} :: ${member} parameters after the first must stay optional`).toMatch(/^\w+\?:/);
+ }
});
it('every IN site carries its own forwarding hop — the criterion, not the name', () => {