From 0cc4ccf8435b6e47ec2eae643f8ff9d5589e9677 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:17:31 +0800 Subject: [PATCH 1/9] wip(app-shell,components): record drawer in-place stack (objectstack#26679) Claude-Session: https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff Co-authored-by: Claude --- .../app-shell/src/views/ObjectView.tsx | 5 +- .../app-shell/src/views/RecordDetailView.tsx | 55 +- .../src/views/RelatedRecordActionsBridge.tsx | 38 +- .../src/views/objectViewRecordNavigation.ts | 60 +- .../src/views/objectViewRecordOverlay.tsx | 61 +- .../src/views/recordDrawerContext.ts | 50 ++ .../app-shell/src/views/recordDrawerStack.tsx | 559 ++++++++++++++++++ .../src/custom/navigation-overlay.tsx | 179 +++++- ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + ui/packages/plugin-detail/src/RelatedList.tsx | 19 +- .../src/renderers/record-related-list.tsx | 12 +- .../context/RelatedRecordActionsContext.tsx | 8 +- 21 files changed, 970 insertions(+), 88 deletions(-) create mode 100644 ui/packages/app-shell/src/views/recordDrawerContext.ts create mode 100644 ui/packages/app-shell/src/views/recordDrawerStack.tsx diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index fe1d0643ebe..4497e92b95a 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -719,7 +719,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // to a record that opens in the drawer: an object that declares `page` // (or `new_window` / `none`) keeps its declaration. const [recordOpenPreference] = useState(readRecordOpenPreference); - const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({ + const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore } = useObjectViewRecordNavigation({ activeView, objectDef, objects, @@ -1147,9 +1147,10 @@ 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 && ( +
+ {originFrom?.pathname && originFrom?.label && ( + + + {originFrom.label} + + )} +
+ +
+
)} {/* 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/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts index a71a02fbcc8..c3dd3d68d7e 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -4,6 +4,12 @@ * `?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'; @@ -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,39 @@ 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 drawer's root — the list row `?recordId=` names — or `null`. */ + const drawerRoot = useMemo( + () => (drawerRecordId ? { objectName: objectDef.name, recordId: drawerRecordId } : null), + [drawerRecordId, objectDef.name], + ); // Sync URL-based recordId to overlay state useEffect(() => { if (drawerRecordId && !navOverlay.isOpen) { @@ -211,5 +249,5 @@ export function useObjectViewRecordNavigation({ navOverlay.close(); } }, [drawerRecordId]); - return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer }; + return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore }; } diff --git a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx index b09358bfb49..723864c79ab 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx +++ b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx @@ -6,57 +6,66 @@ * 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, 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']; t: ReturnType['t']; dataSource: ConsoleObjectViewProps['dataSource']; objects: ConsoleObjectViewProps['objects']; onEdit: ConsoleObjectViewProps['onEdit']; }) { + const stack = useRecordDrawerStack({ root: drawerRoot, restoredPath: drawerRestore }); 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..8fc872effe1 --- /dev/null +++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx @@ -0,0 +1,559 @@ +/** + * 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(); + const stateRef = useRef(location.state); + stateRef.current = location.state; + const read = useCallback( + () => new URLSearchParams(window.location.search).get(RECORD_DETAIL_TAB_PARAM), + [], + ); + const write = useCallback( + (tab: string | null | undefined) => { + const sp = new URLSearchParams(window.location.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: stateRef.current }); + }, + [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; + 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(); + navigate(to, { + state: recordDrawerReturnState( + { pathname: location.pathname, search: searchText ? `?${searchText}` : '', state: location.state }, + path, + ), + }); + }, + [appName, objects, pageObjectName, pageRecordId, pageTitle, location, navigate], + ); + + 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/custom/navigation-overlay.tsx b/ui/packages/components/src/custom/navigation-overlay.tsx index 4d19b040454..b14187e24a8 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). */} -
- - {resolvedTitle} - - {description ? ( - {description} - ) : ( - - {overlayDescription} - +
+ {resolvedTitle} + + {description || overlayDescription} + + {onBack && ( + )} + {trail}
+ {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/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 8734575d302..f5235e99601 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1231,6 +1231,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 1c8c60aa99c..9529c9b37eb 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1105,6 +1105,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 5b9022a3423..45946911435 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1442,6 +1442,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 2777ee04b1d..312891af1b9 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1138,6 +1138,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 742820a60e2..4b0c5d4367d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1135,6 +1135,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 ec0c1814b4a..1184d0cbf17 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1105,6 +1105,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 c3f6c2b3357..e574b30d96b 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1105,6 +1105,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 3ea5434ca82..affbcc87bc8 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1135,6 +1135,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 57ace4f220b..f8089563338 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1172,6 +1172,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 faed640b2b9..649f906dc1a 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1180,6 +1180,7 @@ const zh = { recordDetail: '记录详情', recordDetailWithLabel: '{{label}}详情', openAsFullPage: '以完整页面打开', + collapseToDrawer: '收为抽屉', recordDetailOverlay: '{{title}} 的记录详情浮层。', addToFavorites: '添加到收藏', removeFromFavorites: '从收藏中移除', 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/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index 28e315046c3..c32d0b06485 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,18 @@ 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; + const siblings = rows + ?.map(rowId) + .filter((sibling): sibling is string | number => sibling != null); + handlers.onView!(id, row, siblings); } : undefined } 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 From 527595428a93109f48b3dbea050b6a996a3582ca Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:28:57 +0800 Subject: [PATCH 2/9] wip(app-shell,components,plugin-detail): drawer stack tests and fixes (objectstack#26679) Claude-Session: https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff Co-authored-by: Claude --- .../recordDrawerStack-26679.test.tsx | 339 ++++++++++++++++++ .../app-shell/src/views/recordDrawerStack.tsx | 19 +- .../navigation-overlay-bar-26679.test.tsx | 125 +++++++ .../src/custom/navigation-overlay.tsx | 8 +- ...rdRelatedListRenderer.hostactions.test.tsx | 5 + ...elatedList.rowClickSiblings-26679.test.tsx | 94 +++++ .../src/renderers/record-related-list.tsx | 6 +- 7 files changed, 585 insertions(+), 11 deletions(-) create mode 100644 ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx create mode 100644 ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx 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 ( + <> + {`${location.pathname}${location.search}`} + {JSON.stringify(location.state ?? null)} + + ); +} + +/** 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/recordDrawerStack.tsx b/ui/packages/app-shell/src/views/recordDrawerStack.tsx index 8fc872effe1..8558cb88d2f 100644 --- a/ui/packages/app-shell/src/views/recordDrawerStack.tsx +++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx @@ -126,19 +126,21 @@ const levelKey = (level: { objectName: string; recordId: string } | null | undef function useRecordTabParam() { const [, setSearchParams] = useSearchParams(); const location = useLocation(); - const stateRef = useRef(location.state); - stateRef.current = location.state; + // 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(window.location.search).get(RECORD_DETAIL_TAB_PARAM), + () => new URLSearchParams(locationRef.current.search).get(RECORD_DETAIL_TAB_PARAM), [], ); const write = useCallback( (tab: string | null | undefined) => { - const sp = new URLSearchParams(window.location.search); + 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: stateRef.current }); + setSearchParams(sp, { replace: true, state: locationRef.current.state }); }, [setSearchParams], ); @@ -523,10 +525,15 @@ export function RecordPageDrawerHost({ : 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 }, @@ -534,7 +541,7 @@ export function RecordPageDrawerHost({ ), }); }, - [appName, objects, pageObjectName, pageRecordId, pageTitle, location, navigate], + [appName, objects, pageObjectName, pageRecordId, pageTitle, location, navigate, clearRestored], ); const rootDef = root ? objects.find((o) => (o as { name?: unknown } | null)?.name === root.objectName) : undefined; 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/custom/navigation-overlay.tsx b/ui/packages/components/src/custom/navigation-overlay.tsx index b14187e24a8..e25dc224d80 100644 --- a/ui/packages/components/src/custom/navigation-overlay.tsx +++ b/ui/packages/components/src/custom/navigation-overlay.tsx @@ -585,11 +585,11 @@ export const NavigationOverlay: React.FC = ({ No object name and no position: the title and description stay for assistive technology only (Radix requires the title). */} + {resolvedTitle} + + {description || overlayDescription} +
- {resolvedTitle} - - {description || overlayDescription} - {onBack && (