Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/27545-record-pane-v4-mechanics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@object-ui/components': patch
'@object-ui/plugin-grid': patch
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
---

feat(components,plugin-grid,i18n,app-shell): record pane v4 mechanics — the split pane beside the list, the non-modal drawer over it, ⤢ maximize in place, the drag range, and the list's narrowed faces (#27545, part A)

Clause-②: yes (widening)
Metadata-protocol: none

No metadata key is added, removed or read differently. The console wires these mechanics in a following change; until then it passes none of the new props, and its only change here is the pane's default width.

- **`@object-ui/components`**: `NavigationOverlay`.
- **Split face.** The pane sits beside the list the host hands over (`mainContent`), at that list's full height. The list is one element in every state, so it is not remounted by the overlay. Below a 1000 px content area (`RECORD_SPLIT_MIN_CONTENT_PX`) the split face falls back to the drawer.
- **Drawer.** Over a `mainContent` list, the drawer is non-modal and draws no dimming overlay: an outside click leaves it open, and ✕ and `Esc` close it. A drawer with no list is the modal sheet it was.
- **Width.** The divider drags from 360 px to 60vw. A remembered width is drawn inside that range (`min(Npx, 60vw)`). An authored width is no longer floored at `min(60vw, 880px)`.
- **New `maximizable` prop.** It draws ⤢ / ⤡ and maximizes the pane in place. The pane's width becomes the viewport less the live sidebar width (`--sidebar-width`, or `--sidebar-width-icon` when collapsed; `SIDEBAR_WIDTH_FALLBACK` / `SIDEBAR_WIDTH_ICON_FALLBACK` where the variable does not reach). The list stays mounted and inert under it. `Esc` restores, and a closed pane forgets it.
- **New `activeListRecordId` prop.** A list under the pane reads its narrowed face (`useRecordListFace()`: `columns` at ≥ 440 px, `cards` below, `full` with no pane beside it) and which of its rows is the open record (`ActiveListRow`, `useIsActiveListRecord`, `ActiveListRecordProvider`, `RecordListFaceProvider`). The open row is a per-row subscription, so a change re-renders the row it leaves and the row it reaches, not the list.
- **`onExpand`** is drawn as ↗.
- **`DataTable`** marks the open record's row (`data-active-record`, `aria-current`) and scrolls it into view.
- **`@object-ui/plugin-grid`**: `ObjectGrid` narrowed to `columns` keeps its first three view columns that are not avatars, plus its row actions. Narrowed to `cards`, it draws one card row per record, with the name and two chips. Under no pane nothing changes.
- **`@object-ui/i18n`**: `detail.maximize` and `detail.restoreSize` in all ten packs.
- **`@object-ui/app-shell`**: the console's record pane defaults to 720 px (`md`, clamped to 92vw) whatever the object's field count. `size: 'auto'` and no size both read as `md`, and an authored `size` or `width` stands. This applies to the object page's drawer and to the record page's related-record drawer.

What a console reader sees from this change alone:

- The record drawer's page button is drawn as ↗ instead of ⤢. It still opens the full page, under the same name.
- The record drawer is 720 px wide (at most 92vw) whatever the object's field count. Before, it took 480 to 1200 px by field count and was widened to at least `min(60vw, 880px)`: 864 px at a 1440 px viewport for an object of up to eight fields.
- A reader who chose Split in the Display menu sees the pane as a side panel. It draws no title heading. Its divider drags from 360 px to 60vw. The list beside it narrows to three columns, or to card rows below 440 px. The open row is not tinted yet: the console does not pass `activeListRecordId` until the following change. Below a 1000 px content area, the pane becomes a non-modal drawer over the list.
27 changes: 20 additions & 7 deletions ui/packages/app-shell/src/views/objectViewRecordNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import type { buildViewTabs } from './objectViewTabs.js';
import type { ConsoleObjectViewProps } from './ObjectView.js';

type OverlaySizeBucket = 'sm' | 'md' | 'lg' | 'xl' | 'full';

/**
* The record pane's size bucket (objectstack#27545): `auto` — or no size —
* is `md` (720 px) whatever the object's field count; an authored bucket
* stands as authored.
*/
function recordPaneSize(size: 'auto' | OverlaySizeBucket | undefined): OverlaySizeBucket {
return !size || size === 'auto' ? 'md' : size;
}

export function useObjectViewRecordNavigation({
activeView,
objectDef,
Expand Down Expand Up @@ -78,24 +89,26 @@ export function useObjectViewRecordNavigation({
const authored = activeView?.navigation ?? objectDef.navigation;
if (authored) {
// Authored config wins. For an overlay, resolve the `size`
// bucket (or 'auto') to a viewport-clamped width when no explicit
// width was given — objectstack-ai/objectstack#2578: a pixel width can't be authored blind.
// bucket to a viewport-clamped width when no explicit width was
// given — objectstack-ai/objectstack#2578: a pixel width can't
// be authored blind. `auto` is the pane's 720 (objectstack#27545).
if (authored.mode === 'page') return authored;
return {
...authored,
width: authored.width ?? overlayWidthFor(
(authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size,
recordPaneSize((authored as { size?: 'auto' | OverlaySizeBucket }).size),
objectDef,
),
};
}
// No authored navigation: the drawer, whatever the object's field
// count. Until objectstack#26677 a field-heavy object paged here by
// itself (`deriveRecordSurface`, objectstack-ai/objectstack#2578), a
// surface no author chose; `page` is now a declaration only. The
// drawer's WIDTH still follows the field count (`size: 'auto'`),
// clamped to the viewport.
return { mode: 'drawer', width: overlayWidthFor('auto', objectDef) };
// surface no author chose; `page` is now a declaration only. Since
// objectstack#27545 the pane's width no longer follows the field
// count either: `md` (720 px), clamped to the viewport, which the
// reader drags.
return { mode: 'drawer', width: overlayWidthFor(recordPaneSize(undefined), objectDef) };
},
[activeView?.navigation, objectDef]
);
Expand Down
5 changes: 4 additions & 1 deletion ui/packages/app-shell/src/views/recordDrawerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronRight, Minimize2 } from 'lucide-react';
import { NavigationOverlay, type NavigationOverlayMode } from '@object-ui/components';
import { overlayWidthFor } from '@object-ui/plugin-view';
import type { FieldContainerLike } from '@object-ui/core';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import { prefetchRelatedCounts, relatedCountTargetsIn } from '@object-ui/plugin-detail';
Expand Down Expand Up @@ -625,7 +626,9 @@ export function RecordPageDrawerHost({
);

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;
// The pane's own width (objectstack#27545): an authored one, else `md`.
const rootWidth = (rootDef as { navigation?: { width?: string | number } } | undefined)?.navigation?.width
?? overlayWidthFor('md', rootDef);

return (
<RecordDrawerContext.Provider value={api}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
* `RecordDetailDrawer`'s retired `objectui.drawerWidth.OBJECT`, must carry
* over — ⛔ not be silently reset. The starting state of every case below is
* therefore an EXISTING persisted width; a case that only checked the new key
* would prove nothing about the migration.
* would prove nothing about the migration. Since objectstack#27545 a
* remembered width draws inside the drag range (`min(Npx, 60vw)`), and an
* authored width is no longer floored at `min(60vw, 880px)`: the pane's
* default is 720 and the reader drags it.
*/

import React from 'react';
Expand Down Expand Up @@ -121,8 +124,9 @@ describe('objectui#9299 item 4 — the persisted width carries over, it is not r
legacyStorageKey: LEGACY,
});
expect(await screen.findByTestId('payload')).toBeInTheDocument();
// The user's width is what renders — not the authored default.
expect(readWidth()).toBe('640px');
// The user's width is what renders — not the authored default — bounded
// by the drag range (objectstack#27545).
expect(readWidth()).toBe('min(640px, 60vw)');
// … and it has MOVED, so this is a migration and not a permanent second
// read: the new key holds it and the retired one is gone.
expect(window.localStorage.getItem(KEY)).toBe('640');
Expand All @@ -138,7 +142,7 @@ describe('objectui#9299 item 4 — the persisted width carries over, it is not r
legacyStorageKey: LEGACY,
});
expect(await screen.findByTestId('payload')).toBeInTheDocument();
expect(readWidth()).toBe('700px');
expect(readWidth()).toBe('min(700px, 60vw)');
});

it('CONTROL: with no persisted width at all the authored width is what applies', async () => {
Expand All @@ -151,7 +155,7 @@ describe('objectui#9299 item 4 — the persisted width carries over, it is not r
legacyStorageKey: LEGACY,
});
expect(await screen.findByTestId('payload')).toBeInTheDocument();
expect(readWidth()).toBe('max(min(960px, 60vw), min(60vw, 880px))');
expect(readWidth()).toBe('min(960px, 60vw)');
});

it('a legacy width below the shared floor is not adopted', async () => {
Expand All @@ -167,7 +171,7 @@ describe('objectui#9299 item 4 — the persisted width carries over, it is not r
legacyStorageKey: LEGACY,
});
expect(await screen.findByTestId('payload')).toBeInTheDocument();
expect(readWidth()).toBe('max(min(960px, 60vw), min(60vw, 880px))');
expect(readWidth()).toBe('min(960px, 60vw)');
expect(window.localStorage.getItem(KEY)).toBeNull();
});

Expand Down
Loading
Loading