From 9be8e1c8802c32ec8bfbe10feb8633fde17c5a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:14:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20record=20pane=20v4=20mechanics=20?= =?UTF-8?q?=E2=80=94=20split=20beside=20the=20list,=20non-modal=20drawer?= =?UTF-8?q?=20over=20it,=20maximize=20in=20place,=20narrowed=20list=20face?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A of objectstack#27545 (Refs, not Fixes): the components, grid and i18n mechanics the console wires in the following change, plus the console's 720 px default pane width. - NavigationOverlay: the split face beside the host's list, the non-modal drawer over a list, the 360 px–60vw drag range, the `maximizable` ⤢/⤡ in place, `activeListRecordId`, ↗ for `onExpand`. - record-list-face: the narrowed list faces and the open-row subscription; DataTable marks and scrolls the open row. - ObjectGrid: three columns beside the pane, card rows below 440 px. - i18n: detail.maximize / detail.restoreSize in all ten packs. - app-shell: the pane defaults to md (720 px) whatever the field count. Claude-Session: https://claude.ai/code/session_01Utp6CY5hd7qEmdXd6Wg8Ap Co-authored-by: Claude --- .changeset/27545-record-pane-v4-mechanics.md | 31 + .../src/views/objectViewRecordNavigation.ts | 27 +- .../app-shell/src/views/recordDrawerStack.tsx | 5 +- .../navigation-overlay-9299.test.tsx | 16 +- .../navigation-overlay-split-27545.test.tsx | 275 +++++++ .../__tests__/record-list-face-27545.test.tsx | 133 ++++ ui/packages/components/src/custom/index.ts | 13 + .../src/custom/navigation-overlay.tsx | 690 +++++++++++------- .../src/custom/record-list-face.tsx | 156 ++++ .../src/renderers/complex/data-table.tsx | 17 +- ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + .../ObjectCalendar.navWidthDefault.test.tsx | 30 +- .../calendarNavigationMembers-8652.test.tsx | 13 +- .../src/ObjectGantt.navWidthDefault.test.tsx | 29 +- ui/packages/plugin-grid/src/ObjectGrid.tsx | 103 ++- .../ObjectGrid.recordPaneFace-27545.test.tsx | 120 +++ .../gridNavigationMembers-8071.test.tsx | 17 +- .../src/ObjectKanban.navWidthDefault.test.tsx | 30 +- .../kanbanNavigationMembers-8652.test.tsx | 13 +- .../timelineNavigationMembers-8654.test.tsx | 13 +- 29 files changed, 1395 insertions(+), 356 deletions(-) create mode 100644 .changeset/27545-record-pane-v4-mechanics.md create mode 100644 ui/packages/components/src/__tests__/navigation-overlay-split-27545.test.tsx create mode 100644 ui/packages/components/src/__tests__/record-list-face-27545.test.tsx create mode 100644 ui/packages/components/src/custom/record-list-face.tsx create mode 100644 ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordPaneFace-27545.test.tsx diff --git a/.changeset/27545-record-pane-v4-mechanics.md b/.changeset/27545-record-pane-v4-mechanics.md new file mode 100644 index 00000000000..f5351adf514 --- /dev/null +++ b/.changeset/27545-record-pane-v4-mechanics.md @@ -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. diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts index 70cbe7d0ae5..36cf67fc18c 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -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, @@ -78,13 +89,14 @@ 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, ), }; @@ -92,10 +104,11 @@ export function useObjectViewRecordNavigation({ // 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] ); diff --git a/ui/packages/app-shell/src/views/recordDrawerStack.tsx b/ui/packages/app-shell/src/views/recordDrawerStack.tsx index fd90bf3537a..85dc81a6949 100644 --- a/ui/packages/app-shell/src/views/recordDrawerStack.tsx +++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx @@ -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'; @@ -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 ( diff --git a/ui/packages/components/src/__tests__/navigation-overlay-9299.test.tsx b/ui/packages/components/src/__tests__/navigation-overlay-9299.test.tsx index c5c4e87200d..3d3cc6bfd9f 100644 --- a/ui/packages/components/src/__tests__/navigation-overlay-9299.test.tsx +++ b/ui/packages/components/src/__tests__/navigation-overlay-9299.test.tsx @@ -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'; @@ -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'); @@ -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 () => { @@ -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 () => { @@ -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(); }); diff --git a/ui/packages/components/src/__tests__/navigation-overlay-split-27545.test.tsx b/ui/packages/components/src/__tests__/navigation-overlay-split-27545.test.tsx new file mode 100644 index 00000000000..dc91604d74a --- /dev/null +++ b/ui/packages/components/src/__tests__/navigation-overlay-split-27545.test.tsx @@ -0,0 +1,275 @@ +/** + * 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#27545 — the record pane v4 mechanics `NavigationOverlay` owns + * (maintainer rulings of 2026-10-29): + * + * 1. `split` draws the pane beside the list, non-modal and undimmed: the list + * is the one `mainContent` element in every state, so it never remounts, + * stays clickable, and reads the face it is narrowed to (`columns` at + * ≥ 440 px, `cards` below, `full` with no pane beside it). + * 2. Below a 1000 px content area the split face falls back to the drawer, + * which is non-modal over the list too: no overlay, an outside click keeps + * it open. A drawer with no list stays the modal sheet (the control). + * 3. `⤢` maximizes in place to the viewport less the LIVE sidebar width; + * `⤡` or `Esc` restores, `✕` closes, and a closed pane forgets it. + * The drag range is 360 px to 60vw, and a remembered width is drawn inside it. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent, act } from '@testing-library/react'; +import { + NavigationOverlay, + RECORD_SPLIT_MIN_CONTENT_PX, + SIDEBAR_WIDTH_FALLBACK, + SIDEBAR_WIDTH_ICON_FALLBACK, + type NavigationOverlayProps, +} from '../custom/navigation-overlay'; +import { useRecordListFace, RECORD_LIST_CARD_FACE_BELOW_PX } from '../custom/record-list-face'; +import { SidebarProvider } from '../ui/sidebar'; + +let listMounts = 0; + +/** The list a host hands over: counts its mounts, prints its face. */ +function ListProbe({ onRowClick }: { onRowClick?: () => void }) { + React.useEffect(() => { + listMounts += 1; + }, []); + const face = useRecordListFace(); + return ( +
+ {face} + +
+ ); +} + +/** Rendered widths by test id; everything else measures 0 (unmeasured). */ +let widths: Record = {}; +const realRect = HTMLElement.prototype.getBoundingClientRect; + +beforeEach(() => { + listMounts = 0; + widths = {}; + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + const width = widths[this.dataset.testid ?? ''] ?? 0; + return { width, height: 0, top: 0, left: 0, right: width, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; + }; + try { window.localStorage.clear(); } catch { /* private mode */ } +}); +afterEach(() => { + cleanup(); + HTMLElement.prototype.getBoundingClientRect = realRect; +}); + +const list = ; + +function overlay(props: Partial = {}) { + return ( + {}} + setIsOpen={() => {}} + title="Lead" + width="720px" + mainContent={list} + maximizable + {...props} + > + {() =>
record body
} +
+ ); +} + +const pane = () => screen.queryByTestId('record-split-pane'); +const listFace = () => screen.getByTestId('list-face').textContent; + +describe('1 — split is beside the list, non-modal, and the list never remounts', () => { + it('draws the pane as a landmark beside the list, no dialog, the list still clickable', () => { + const onRowClick = vi.fn(); + render(overlay({ mainContent: })); + expect(pane()).toBeTruthy(); + expect(pane()!.tagName).toBe('ASIDE'); + expect(screen.getByRole('complementary', { name: 'Lead' })).toBe(pane()); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByTestId('payload')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Row two' })); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('record-split-list').hasAttribute('inert')).toBe(false); + }); + + it('keeps one list element from closed to open to another record to closed', () => { + const { rerender } = render(overlay({ isOpen: false, selectedRecord: null })); + expect(pane()).toBeNull(); + const listNode = screen.getByTestId('record-split-list').firstElementChild; + rerender(overlay()); + rerender(overlay({ selectedRecord: { id: 'r2' } })); + rerender(overlay({ isOpen: false, selectedRecord: null })); + expect(listMounts).toBe(1); + expect(screen.getByTestId('record-split-list').firstElementChild).toBe(listNode); + }); + + it('a page-mode host still draws its list, and no record', () => { + render(overlay({ mode: 'page' })); + expect(screen.getByTestId('list-face')).toBeTruthy(); + expect(pane()).toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('the list reads its narrowed face: columns, then cards below 440 px, full when closed', () => { + widths = { 'record-split-host': 1440, 'record-split-list': 720 }; + render(overlay()); + expect(listFace()).toBe('columns'); + cleanup(); + widths = { 'record-split-host': 1100, 'record-split-list': RECORD_LIST_CARD_FACE_BELOW_PX - 1 }; + render(overlay()); + expect(listFace()).toBe('cards'); + cleanup(); + widths = { 'record-split-host': 1440, 'record-split-list': 720 }; + const { rerender } = render(overlay({ isOpen: false, selectedRecord: null })); + expect(listFace()).toBe('full'); + rerender(overlay()); + expect(listFace()).toBe('columns'); + }); +}); + +describe('2 — under a 1000 px content area the split face is the non-modal drawer', () => { + it(`falls back below ${RECORD_SPLIT_MIN_CONTENT_PX} px, the list behind it full-width`, () => { + widths = { 'record-split-host': RECORD_SPLIT_MIN_CONTENT_PX - 1, 'record-split-list': 999 }; + render(overlay()); + expect(pane()).toBeNull(); + expect(screen.getByRole('dialog', { name: 'Lead' })).toBeTruthy(); + expect(screen.getByTestId('payload')).toBeTruthy(); + expect(listFace()).toBe('full'); + }); + + it('at exactly 1000 px the pane stays beside the list (control)', () => { + widths = { 'record-split-host': RECORD_SPLIT_MIN_CONTENT_PX, 'record-split-list': 280 }; + render(overlay()); + expect(pane()).toBeTruthy(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('the drawer draws no overlay and an outside click does not close it', () => { + const setIsOpen = vi.fn(); + render(overlay({ mode: 'drawer', setIsOpen })); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(document.querySelector('[data-slot="sheet-overlay"], .bg-black\\/50, .bg-black\\/80')).toBeNull(); + fireEvent.pointerDown(screen.getByRole('button', { name: 'Row two' })); + fireEvent.click(screen.getByRole('button', { name: 'Row two' })); + expect(setIsOpen).not.toHaveBeenCalledWith(false); + expect(screen.getByRole('dialog')).toBeTruthy(); + }); + + it('CONTROL: a drawer with no list is the modal, dimmed sheet over the page', () => { + render(overlay({ mode: 'drawer', mainContent: undefined })); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(document.querySelector('.bg-black\\/80')).not.toBeNull(); + }); +}); + +describe('3 — ⤢ maximizes in place to the viewport less the live sidebar', () => { + const maximize = () => screen.getByTestId('record-pane-maximize'); + const paneWidth = () => pane()!.style.getPropertyValue('--ov-w').trim(); + + it('⤢ maximizes over the list, ⤡ restores; the list keeps its element and goes inert', () => { + render(overlay()); + expect(maximize().getAttribute('aria-label')).toBe('Maximize'); + expect(paneWidth()).toBe('720px'); + fireEvent.click(maximize()); + expect(pane()!.hasAttribute('data-maximized')).toBe(true); + expect(paneWidth()).toBe('calc(100vw - 0px)'); + expect(screen.getByTestId('record-split-list').hasAttribute('inert')).toBe(true); + expect(listFace()).toBe('full'); + expect(maximize().getAttribute('aria-label')).toBe('Restore size'); + fireEvent.click(maximize()); + expect(pane()!.hasAttribute('data-maximized')).toBe(false); + expect(paneWidth()).toBe('720px'); + expect(listMounts).toBe(1); + }); + + it('Esc restores from maximized, and only the next Esc closes', () => { + const close = vi.fn(); + render(overlay({ close })); + fireEvent.click(maximize()); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(pane()!.hasAttribute('data-maximized')).toBe(false); + expect(close).not.toHaveBeenCalled(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('✕ closes the whole path even while maximized', () => { + const close = vi.fn(); + render(overlay({ close })); + fireEvent.click(maximize()); + fireEvent.click(screen.getByRole('button', { name: 'Close panel' })); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('is not remembered: a pane closed while maximized opens at its own width', () => { + const { rerender } = render(overlay()); + fireEvent.click(maximize()); + rerender(overlay({ isOpen: false, selectedRecord: null })); + rerender(overlay()); + expect(pane()!.hasAttribute('data-maximized')).toBe(false); + expect(paneWidth()).toBe('720px'); + }); + + it('reads the sidebar live: expanded, then collapsed to its icon rail', () => { + function Shell({ open }: { open: boolean }) { + return {}}>{overlay()}; + } + const { rerender } = render(); + fireEvent.click(maximize()); + expect(paneWidth()).toBe(`calc(100vw - var(--sidebar-width, ${SIDEBAR_WIDTH_FALLBACK}))`); + rerender(); + expect(paneWidth()).toBe(`calc(100vw - var(--sidebar-width-icon, ${SIDEBAR_WIDTH_ICON_FALLBACK}))`); + }); + + it('the fallbacks a portalled drawer reads are the sidebar provider’s own widths', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.style.getPropertyValue('--sidebar-width')).toBe(SIDEBAR_WIDTH_FALLBACK); + expect(wrapper.style.getPropertyValue('--sidebar-width-icon')).toBe(SIDEBAR_WIDTH_ICON_FALLBACK); + }); +}); + +describe('the drag range: 360 px to 60vw, remembered per object, a remembered width clamped', () => { + const paneWidth = () => pane()!.style.getPropertyValue('--ov-w').trim(); + + it('a remembered 1300 is drawn inside the range', () => { + window.localStorage.setItem('ov:drawer-width:lead', '1300'); + render(overlay({ storageKey: 'drawer-width:lead' })); + expect(paneWidth()).toBe('min(1300px, 60vw)'); + }); + + it('a drag stops at 60vw and at 360 px, and the width is remembered under the key', () => { + render(overlay({ storageKey: 'drawer-width:lead' })); + const handle = screen.getByRole('separator'); + const maxPx = Math.floor((window.innerWidth * 60) / 100); + act(() => { + fireEvent.mouseDown(handle); + fireEvent.mouseMove(window, { clientX: -5000 }); + }); + expect(paneWidth()).toBe(`min(${maxPx}px, 60vw)`); + act(() => { + fireEvent.mouseMove(window, { clientX: 5000 }); + fireEvent.mouseUp(window); + }); + expect(paneWidth()).toBe('min(360px, 60vw)'); + expect(window.localStorage.getItem('ov:drawer-width:lead')).toBe('360'); + }); +}); diff --git a/ui/packages/components/src/__tests__/record-list-face-27545.test.tsx b/ui/packages/components/src/__tests__/record-list-face-27545.test.tsx new file mode 100644 index 00000000000..4741c5cc6f0 --- /dev/null +++ b/ui/packages/components/src/__tests__/record-list-face-27545.test.tsx @@ -0,0 +1,133 @@ +/** + * 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#27545 — the list beside an open record marks the row the pane + * shows. The open row follows every row click and `↑` / `↓` step, scrolls + * into view, and a change re-renders the row it leaves and the row it + * reaches — never the list around them (`record-list-face.tsx`). + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { + ActiveListRecordProvider, + ActiveListRow, + listRecordIdOf, + recordListFaceFor, + RECORD_LIST_CARD_FACE_BELOW_PX, +} from '../custom/record-list-face'; +import { ComponentRegistry } from '@object-ui/core'; +// Registers the renderers at module scope (objectui#3010). +import '../renderers'; + +afterEach(() => cleanup()); + +const IDS = ['a', 'b', 'c']; +/** Renders counted per component: the list, and each row by its id. */ +const renders = { list: 0, rows: {} as Record }; + +function Row({ id }: { id: string }) { + return ( + recordId={id}> + {(active, ref) => { + renders.rows[id] = (renders.rows[id] ?? 0) + 1; + return ( +
+ {id} +
+ ); + }} + + ); +} + +const List = React.memo(function List() { + renders.list += 1; + return ( +
+ {IDS.map((id) => ( + + ))} +
+ ); +}); + +const activeRows = () => IDS.filter((id) => screen.getByTestId(`row-${id}`).hasAttribute('data-active')); + +describe('the open row', () => { + it('follows the open record, re-rendering only the rows it leaves and reaches', () => { + const scrolled: string[] = []; + const spy = vi + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(function (this: HTMLElement) { + scrolled.push(this.dataset.testid ?? ''); + }); + const list = ; + const { rerender } = render({list}); + expect(activeRows()).toEqual(['a']); + expect(scrolled).toEqual(['row-a']); + const before: Record = { list: renders.list, ...renders.rows }; + + rerender({list}); + expect(activeRows()).toEqual(['b']); + expect(scrolled).toEqual(['row-a', 'row-b']); + expect(renders.list).toBe(before.list); + expect(renders.rows.a).toBe(before.a + 1); + expect(renders.rows.b).toBe(before.b + 1); + expect(renders.rows.c).toBe(before.c); + + rerender({list}); + expect(activeRows()).toEqual([]); + spy.mockRestore(); + }); + + it('no provider: no row is the open one', () => { + render(); + expect(activeRows()).toEqual([]); + }); + + it('a row is named by `id`, else `_id`, as text', () => { + expect(listRecordIdOf({ id: 7 })).toBe('7'); + expect(listRecordIdOf({ _id: 'x' })).toBe('x'); + expect(listRecordIdOf({ id: '' })).toBeNull(); + expect(listRecordIdOf(null)).toBeNull(); + }); + + it('the narrowed face turns to cards below 440 px', () => { + expect(recordListFaceFor(RECORD_LIST_CARD_FACE_BELOW_PX)).toBe('columns'); + expect(recordListFaceFor(RECORD_LIST_CARD_FACE_BELOW_PX - 1)).toBe('cards'); + }); +}); + +describe('the data table marks the open row', () => { + it('tints and names the row of the open record, and moves with it', () => { + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {}); + const schema = { + type: 'data-table', + columns: [{ header: 'Name', accessorKey: 'name' }], + data: [ + { id: 'a', name: 'Ada' }, + { id: 'b', name: 'Bo' }, + ], + pagination: false, + searchable: false, + } as never; + const DataTable = ComponentRegistry.get('data-table')!; + const table = ; + const { rerender } = render({table}); + const rowOf = (name: string) => screen.getByText(name).closest('tr')!; + expect(rowOf('Bo').hasAttribute('data-active-record')).toBe(true); + expect(rowOf('Bo').getAttribute('aria-current')).toBe('true'); + expect(rowOf('Ada').hasAttribute('data-active-record')).toBe(false); + rerender({table}); + expect(rowOf('Ada').hasAttribute('data-active-record')).toBe(true); + expect(rowOf('Bo').hasAttribute('data-active-record')).toBe(false); + }); +}); diff --git a/ui/packages/components/src/custom/index.ts b/ui/packages/components/src/custom/index.ts index 3080c985390..aea80e045e3 100644 --- a/ui/packages/components/src/custom/index.ts +++ b/ui/packages/components/src/custom/index.ts @@ -16,6 +16,19 @@ export * from './item'; export * from './kbd'; export * from './native-select'; export * from './navigation-overlay'; +export { + RecordListFaceProvider, + ActiveListRecordProvider, + recordListFaceFor, + useRecordListFace, + useIsActiveListRecord, + useScrollActiveListRecordIntoView, + ActiveListRow, + listRecordIdOf, + RECORD_LIST_CARD_FACE_BELOW_PX, + RECORD_LIST_NARROW_COLUMN_COUNT, + type RecordListFace, +} from './record-list-face'; export * from './panel-toggles'; export * from './resizable'; export * from './section-header'; diff --git a/ui/packages/components/src/custom/navigation-overlay.tsx b/ui/packages/components/src/custom/navigation-overlay.tsx index e25dc224d80..a4500d1b03a 100644 --- a/ui/packages/components/src/custom/navigation-overlay.tsx +++ b/ui/packages/components/src/custom/navigation-overlay.tsx @@ -10,11 +10,20 @@ * NavigationOverlay * * A reusable component that renders record detail overlays based on - * ViewNavigationConfig mode: the drawer (Sheet), and the split pane - * (ResizablePanelGroup) a host draws from a reader's own preference. - * `modal` and `popover` were retired from the spec's `NavigationModeSchema` - * (objectstack#26677), and with them the Dialog and Popover faces: no caller - * could reach either once the values left metadata. + * ViewNavigationConfig mode: the drawer (Sheet), and the split pane a host + * draws from a reader's own preference. `modal` and `popover` were retired + * from the spec's `NavigationModeSchema` (objectstack#26677), and with them + * the Dialog and Popover faces: no caller could reach either once the values + * left metadata. + * + * Both faces leave a list the host hands over (`mainContent`) usable + * (objectstack#27545): the drawer over it draws no dimming overlay and is + * non-modal, so a click on another row swaps the record instead of closing + * it; the split pane sits beside it, and it narrows to the remaining width. + * A drawer with no list is the modal sheet over the page, as before. A split pane falls back to the drawer when + * the content area is narrower than {@link RECORD_SPLIT_MIN_CONTENT_PX}, and a + * `maximizable` host gets `⤢`, which widens the pane in place over the whole + * content area — the sidebar stays — without leaving the URL it is on. * * Works in conjunction with useNavigationOverlay hook from @object-ui/react — * the hook manages state while this component handles the visual presentation. @@ -56,7 +65,7 @@ */ import React from 'react'; -import { ArrowLeft, ChevronDown, ChevronUp, Maximize2, X } from 'lucide-react'; +import { ArrowLeft, ArrowUpRight, ChevronDown, ChevronUp, Maximize2, Minimize2, X } from 'lucide-react'; import { cn } from '../lib/utils'; import { Sheet, @@ -64,15 +73,16 @@ import { SheetHeader, SheetTitle, SheetDescription, - SheetClose, } from '../ui/sheet'; -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from './resizable'; +import { useOptionalSidebar } from '../ui/sidebar'; import { usePopperAwareInteractOutside } from './mobile-dialog-content'; import { createSafeTranslation } from '@object-ui/i18n'; +import { + ActiveListRecordProvider, + RecordListFaceProvider, + recordListFaceFor, + type RecordListFace, +} from './record-list-face'; /** * English defaults for every string this overlay renders as chrome @@ -91,13 +101,15 @@ const OVERLAY_DEFAULT_TRANSLATIONS: Record = { // The drag handle is a bare `role="separator"` with no visible label, so // this literal IS the control to a screen reader. 'common.resizeDrawer': 'Resize drawer', - // Default accessible name + tooltip of the icon-only expand button. Hosts - // may override it via the `expandLabel` prop; this is what ships otherwise. + // Default accessible name + tooltip of the icon-only ↗ a host's own page + // hop draws (`onExpand`). Hosts may override it via the `expandLabel` prop. 'detail.openAsFullPage': 'Open as full page', - // 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. + // The bar's ⤢ / ⤡ pair (`maximizable`, objectstack#27545). + 'detail.maximize': 'Maximize', + 'detail.restoreSize': 'Restore size', + // The pane's accessible name when the host passes no `title`. The bar draws + // no title text on either face (objectstack#26679: the record body names + // the record), so this is an accessible name only. 'detail.recordDetail': 'Record Detail', // The bar's back control (`onBack`, objectstack#26679). 'detail.goBack': 'Go back', @@ -143,22 +155,21 @@ export interface NavigationOverlayProps { close: () => void; /** Set open state (for the controlled Sheet's onOpenChange) */ setIsOpen: (open: boolean) => void; - /** Width for the overlay (drawer / split) */ + /** + * Width of the pane (drawer / split) until the reader drags it. Drawn as + * given: no floor widens it (objectstack#27545). + */ width?: string | number; /** Whether navigation is an overlay mode */ isOverlay: boolean; /** Target view/form name from NavigationConfig */ view?: string; /** - * 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). + * The pane's accessible name. Neither face draws title text — the bar holds + * controls only (objectstack#26679, maintainer ruling 2). */ title?: string; - /** - * The overlay's description: drawn under the split face's heading, an - * accessible description only on the drawer (objectstack#26679). - */ + /** The drawer's accessible description (objectstack#26679). */ description?: string; /** CSS class for the overlay container */ className?: string; @@ -174,21 +185,41 @@ export interface NavigationOverlayProps { */ renderView?: (record: Record, viewName: string) => React.ReactNode; /** - * The main content to wrap (for split mode only). - * In split mode, the main content is rendered in the left panel. + * The list the pane opens records from (drawer and split, objectstack#27545). + * + * Drawn whether or not a record is open, always in the same place, so + * opening, stepping and closing never remount it. The split face narrows it + * beside the pane and publishes its narrowed face (`useRecordListFace`); + * both faces publish which row is open (`useIsActiveListRecord`, from + * {@link activeListRecordId}). Pass the same element across renders: an + * unchanged element is not re-rendered when the open record changes. + * + * Without it the split face draws nothing and the drawer floats alone. */ mainContent?: React.ReactNode; /** - * Optional handler invoked when the user clicks the "Expand to full page" - * affordance in the drawer header. Mirrors Linear / Notion / Airtable - * peek-to-full-page behavior — the consumer is responsible for closing the - * overlay and router-pushing to the full record route. + * The id of the row in {@link mainContent} the open record came from — the + * root of a host's in-place stack, not the level it shows (objectstack#27545). + */ + activeListRecordId?: string | null; + /** + * A host's own page hop, drawn as `↗` on the bar. The console's record + * pane passes none: its full page is reached from the list row's `↗` and the + * record header's `⋯` menu (objectstack#27545). * - * When omitted, the expand button is not rendered. + * When omitted, the button is not rendered. */ onExpand?: () => void; /** - * Optional label for the expand button (accessible name & tooltip). + * `⤢` maximize in place (objectstack#27545): the pane widens over the whole + * content area — the viewport less the sidebar's live width — and the list + * behind it is hidden; `⤡` or `Esc` restores the width it had. Nothing else + * changes: the URL, the history, the list's scroll and the host's stack. + * Not remembered: every open starts at the pane's width. + */ + maximizable?: boolean; + /** + * Optional label for the `↗` host page hop (accessible name & tooltip). * * When omitted the overlay resolves `detail.openAsFullPage` from the session * locale (English when no `I18nProvider` is mounted) — it is no longer a @@ -197,7 +228,7 @@ export interface NavigationOverlayProps { expandLabel?: string; /** * Optional storage key for persisting the user's manually-resized drawer - * width (drawer mode only). When provided, the drawer renders a drag + * width (drawer and split). When provided, the pane renders a drag * handle on its left edge and remembers the resized width in * `localStorage` across sessions. Use a stable, scoped key such as * `'drawer-width:lead'` so different objects get independent widths. @@ -208,7 +239,7 @@ export interface NavigationOverlayProps { storageKey?: string; /** * Retired storage key whose value is migrated into `storageKey` once - * (drawer mode only). + * (drawer and split). * * ⭐ objectui#9299 item 4: there used to be TWO drag-resize implementations * with two different persisted keys and two different floors — @@ -226,7 +257,7 @@ export interface NavigationOverlayProps { */ legacyStorageKey?: string; /** - * One level back (drawer mode only, objectstack#26679). When provided the + * One level back (drawer and split, 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 @@ -234,7 +265,7 @@ export interface NavigationOverlayProps { */ onBack?: () => void; /** - * The path drawn after the `←` control (drawer mode only, + * The path drawn after the `←` control (drawer and split, * 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 @@ -242,7 +273,7 @@ export interface NavigationOverlayProps { */ trail?: React.ReactNode; /** - * Previous / next record (drawer mode only, objectstack#26679). When passed + * Previous / next record (drawer and split, 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. @@ -338,11 +369,103 @@ function resolveWidth(width: string | number | undefined): string | undefined { /** Hard floor for drag-resize — narrower than this and the body becomes unusable. */ const DRAWER_MIN_PX = 360; -/** Soft ceiling — keep at least a thin sliver of underlying page visible. */ -const DRAWER_MAX_VW_FACTOR = 0.95; +/** + * The widest a reader can drag the pane, as a share of the viewport + * (objectstack#27545: wider than that is what `⤢` is for). A remembered + * width beyond it draws at this bound. + */ +const DRAWER_MAX_VW = 60; + +/** + * Below this content-area width a split pane draws as the drawer instead + * (objectstack#27545): beside a 720 px pane the list would have no room left. + */ +export const RECORD_SPLIT_MIN_CONTENT_PX = 1000; + +/** + * The sidebar provider's own widths (`--sidebar-width`, `--sidebar-width-icon` + * in `ui/sidebar.tsx`). Inside the provider the variables resolve to their + * live values; these stand in only where they do not resolve — the drawer + * renders in a portal outside the provider's element. + */ +export const SIDEBAR_WIDTH_FALLBACK = '16rem'; +export const SIDEBAR_WIDTH_ICON_FALLBACK = '3rem'; + +/** + * The CSS length the sidebar takes right now: its full width when expanded, + * its icon rail when collapsed, none on a phone or with no sidebar at all. + * Read on every render from the sidebar's own state, so a collapse — the + * reader's or the shell's own — widens a maximized pane at once. + */ +function useLiveSidebarWidth(): string { + const sidebar = useOptionalSidebar(); + if (!sidebar || sidebar.isMobile) return '0px'; + return sidebar.state === 'collapsed' + ? `var(--sidebar-width-icon, ${SIDEBAR_WIDTH_ICON_FALLBACK})` + : `var(--sidebar-width, ${SIDEBAR_WIDTH_FALLBACK})`; +} + +/** The width a maximized pane takes: the viewport less the live sidebar. */ +function maximizedWidthCss(sidebarWidth: string): string { + return `calc(100vw - ${sidebarWidth})`; +} + +/** An element's rendered width, kept current while it resizes; `null` until measured. */ +function useElementWidth(el: HTMLElement | null): number | null { + const [width, setWidth] = React.useState(null); + React.useLayoutEffect(() => { + if (!el) { + setWidth(null); + return; + } + const read = () => { + const next = el.getBoundingClientRect().width; + setWidth(next > 0 ? next : null); + }; + read(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(read); + observer.observe(el); + return () => observer.disconnect(); + }, [el]); + return width; +} + +/** + * Whether an `Esc` belongs to the element it was pressed in rather than to the + * split pane: a text field, or an open menu, list box or dialog (which closes + * itself first and marks the event handled). + */ +function escapeOwnedByTarget(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="listbox"], [role="dialog"], [role="alertdialog"]'); +} /** - * Drawer resize state — drag handle on the left edge, value persisted to + * The split pane's `Esc`, on the document while it is open: the drawer gets + * the same key from its dialog layer, the pane beside the list has none. + */ +function useSplitEscape(active: boolean, onEscape: () => void) { + const handler = React.useRef(onEscape); + handler.current = onEscape; + React.useEffect(() => { + if (!active || typeof document === 'undefined') return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented) return; + if (escapeOwnedByTarget(e.target)) return; + e.preventDefault(); + handler.current(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [active]); +} + +/** + * Pane resize state — drag handle on the left edge, value persisted to * localStorage so the same user gets a consistent width across sessions / * objects. Returns `null` when storageKey is absent (resize disabled). */ @@ -388,19 +511,21 @@ function useDrawerResize( } }, [storageKey, legacyStorageKey]); - const handleMouseDown = React.useCallback((e: React.MouseEvent) => { + const handleMouseDown = React.useCallback((e: React.MouseEvent) => { if (!storageKey || typeof window === 'undefined') return; e.preventDefault(); draggingRef.current = true; - const maxPx = Math.floor(window.innerWidth * DRAWER_MAX_VW_FACTOR); + const maxPx = Math.floor((window.innerWidth * DRAWER_MAX_VW) / 100); + // The pane is right-anchored: the drawer at the viewport's edge, the + // split pane at the content area's (the handle's own pane). + const paneRight = e.currentTarget.parentElement?.getBoundingClientRect().right || window.innerWidth; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = (ev: MouseEvent) => { if (!draggingRef.current) return; - // Drawer is right-anchored: rightX = window.innerWidth, leftX = mouseX. - const next = Math.min(maxPx, Math.max(DRAWER_MIN_PX, window.innerWidth - ev.clientX)); + const next = Math.min(maxPx, Math.max(DRAWER_MIN_PX, paneRight - ev.clientX)); setWidth(next); }; const onUp = () => { @@ -444,9 +569,12 @@ function useDrawerResize( * NavigationOverlay — renders record detail in the configured overlay mode. * * Supports: - * - **drawer**: Right-side Sheet panel - * - **split**: Side-by-side ResizablePanelGroup (a host's preference face) - * - **page / new_window / none**: No overlay rendered (handled by hook) + * - **drawer**: Right-side Sheet panel — non-modal over a `mainContent` list, + * modal over the page without one + * - **split**: a pane beside the list (a host's preference face); the drawer + * when the content area is too narrow for both + * - **page / new_window / none**: no record is drawn (the hook navigates); a + * `mainContent` is still drawn, in the same place */ export const NavigationOverlay: React.FC = ({ isOpen, @@ -462,7 +590,9 @@ export const NavigationOverlay: React.FC = ({ children, renderView, mainContent, + activeListRecordId, onExpand, + maximizable, expandLabel, storageKey, legacyStorageKey, @@ -477,47 +607,65 @@ export const NavigationOverlay: React.FC = ({ const resolvedTitle = title || t('detail.recordDetail'); // `??` rather than a destructuring default so the semantics are unchanged // for hosts that pass the prop: a default parameter also only fires on - // `undefined`, and `ObjectView` already supplies its own translated label. + // `undefined`. const resolvedExpandLabel = expandLabel ?? t('detail.openAsFullPage'); // sr-only Sheet description for the drawer below. const overlayDescription = t('detail.recordDetailOverlay', { title: resolvedTitle }); + const paneMode = mode === 'drawer' || mode === 'split'; // Keep hooks above all conditional returns. Opening a record changes // selectedRecord from null to an object, but hook order must stay stable. const resize = useDrawerResize( - mode === 'drawer' ? storageKey : undefined, - mode === 'drawer' ? legacyStorageKey : undefined, + paneMode ? storageKey : undefined, + paneMode ? legacyStorageKey : undefined, ); - // Inline-edit dropdowns render in body-level poppers; without this guard the - // click that closes an open dropdown also dismisses the drawer (#2156). + // The list is drawn in every mode, in one place, so a host that hands it + // over never remounts it when the mode or the face changes. + const hasList = mainContent != null; + const [hostEl, setHostEl] = React.useState(null); + const [listEl, setListEl] = React.useState(null); + const hostWidth = useElementWidth(hasList ? hostEl : null); + const listWidth = useElementWidth(hasList ? listEl : null); + const shown = paneMode && isOpen && !!selectedRecord; + // An unmeasured host (no layout yet) is taken as wide enough. + const face: 'drawer' | 'split' = + mode === 'split' && !(hostWidth !== null && hostWidth < RECORD_SPLIT_MIN_CONTENT_PX) ? 'split' : 'drawer'; + const [maximizedState, setMaximized] = React.useState(false); + // Not remembered: a closed pane opens at its own width again. + React.useEffect(() => { + if (!shown) setMaximized(false); + }, [shown]); + const maximized = !!maximizable && shown && maximizedState; + const sidebarWidth = useLiveSidebarWidth(); const handleInteractOutside = usePopperAwareInteractOutside(); - useStepperKeys( - mode === 'drawer' && isOpen && !!selectedRecord && !!stepper, - stepper?.onPrevious, - stepper?.onNext, - ); + useStepperKeys(paneMode && shown && !!stepper, stepper?.onPrevious, stepper?.onNext); + // `Esc`: from maximized it restores, below the stack's root it goes back one + // level, at the root it closes. The drawer's dialog layer asks the same + // through `onEscapeKeyDown` below. + const handleEscape = () => { + if (maximized) setMaximized(false); + else if (onBack) onBack(); + else close(); + }; + useSplitEscape(hasList && face === 'split' && shown, handleEscape); - // Non-overlay modes don't render anything - if (mode === 'page' || mode === 'new_window' || mode === 'none') { + // Non-overlay modes draw no record (the hook navigates); only the list. + if (!paneMode && !hasList) { return null; } - - if (!selectedRecord) { + // A split pane needs the list it sits beside. + if (mode === 'split' && !hasList) { return null; } - // Drawer width policy: - // - If the user explicitly drag-resized, honor that exact pixel value - // (their choice always wins). - // - Otherwise, treat the authored width as a *floor*. On wide monitors, - // weakly-authored values like 600px get bumped up to a healthier - // `min(60vw, 880px)` so the drawer doesn't feel cramped on 1920px - // displays. Strongly-authored larger values still win via `max()`. - // - The mobile gate (`sm:` on the className) prevents this from - // affecting phones. - const authoredWidthCss = resolveWidth(width) ?? '42rem'; - const drawerStyle: React.CSSProperties = resize.width != null - ? { ['--ov-w' as any]: `${resize.width}px` } - : { ['--ov-w' as any]: `max(${authoredWidthCss}, min(60vw, 880px))` }; + // Pane width policy: the reader's dragged width, bounded by the drag range + // even when it was remembered at a wider viewport; otherwise the host's + // width as given. A maximized pane takes the content area instead. + const paneWidthCss = resize.width != null + ? `min(${resize.width}px, ${DRAWER_MAX_VW}vw)` + : resolveWidth(width) ?? '42rem'; + const widthStyle: React.CSSProperties = { + ['--ov-w' as any]: maximized ? maximizedWidthCss(sidebarWidth) : paneWidthCss, + }; // Use renderView when both renderView and view are provided; otherwise fallback to children const renderContent = (record: Record) => { @@ -527,194 +675,220 @@ export const NavigationOverlay: React.FC = ({ return children(record); }; - // --- Drawer Mode (Sheet) --- - if (mode === 'drawer') { - return ( - - 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 - // sensible 2xl ceiling so very wide configs don't dwarf the - // page. The host's className still wins on more specific tokens. - 'w-screen max-w-none sm:w-full sm:max-w-[var(--ov-w,42rem)] p-0 flex flex-col gap-0 overflow-hidden', - // Hide shadcn Sheet's auto-rendered close (X) — it's the LAST - // direct + )} + {trail} + +
+ {stepper && ( + <> + + + + )} + {onExpand && ( + + )} + {maximizable && ( + + )} + - )} - {trail} -
-
- {stepper && ( - <> - - - - )} - {onExpand && ( - - )} - - - -
- -
- {renderContent(selectedRecord)} -
-
-
- ); - } + + + + + ); + const barClass = 'shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-4 py-2 border-b bg-muted/30'; - // --- Split Mode (Resizable Panels) --- - if (mode === 'split') { - if (!isOpen || !mainContent) { - return null; - } + // --- Drawer (Sheet) --- + // Over a list the host handed over, non-modal and undimmed + // (objectstack#27545): the list stays readable and clickable, so an outside + // click — another row, the toolbar — never dismisses it; ✕ and `Esc` do. + // With no list it is the modal sheet over the page it always was. + const drawer = paneMode && face === 'drawer' && selectedRecord ? ( + + e.preventDefault() : handleInteractOutside} + // Suppress Radix's default auto-focus on open. The overlay is for + // browsing/inspecting a record, not for immediate keyboard entry, so + // auto-focusing the Close button (or the first focusable child) + // flashes a focus ring on mount which feels jarring. Keyboard users + // still Tab in normally. + // + // This came in with objectui#9299's chrome de-duplication: it was + // `RecordDetailDrawer`'s behaviour, reasoned and deliberate, and the + // ruling makes this component the ONE drawer. Converging on the + // 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#27545 — from maximized, `Esc` restores; objectstack#26679 + // — below the root of an in-place stack, `Esc` goes back one level. + // Only the root's `Esc` closes. + onEscapeKeyDown={maximized || onBack ? (e) => { e.preventDefault(); handleEscape(); } : undefined} + className={cn( + // Mobile: full width (no inline cap, no max-w from base sheet). + // sm+: honor the pane width via the `--ov-w` CSS var. The host's + // className still wins on more specific tokens. + 'w-screen max-w-none sm:w-full sm:max-w-[var(--ov-w,42rem)] p-0 flex flex-col gap-0 overflow-hidden', + // Hide shadcn Sheet's auto-rendered close (X) — it's the LAST + // direct + if (!hasList) return drawer; + + // --- The list, and the split pane beside it --- + // One element across every state — closed, split, drawer, maximized — so + // the list never remounts and its scroll survives (objectstack#27545). The + // pane is one element too: maximizing restyles it, it does not remount it. + const paneBeside = face === 'split' && shown; + const listFace: RecordListFace = paneBeside && !maximized + ? listWidth !== null ? recordListFaceFor(listWidth) : 'columns' + : 'full'; + return ( + <> + + +
+
+ {mainContent}
- {description && ( -

{description}

+ {paneBeside && selectedRecord && ( + )} - {renderContent(selectedRecord)}
- - - ); - } - - return null; +
+
+ {drawer} + + ); }; NavigationOverlay.displayName = 'NavigationOverlay'; diff --git a/ui/packages/components/src/custom/record-list-face.tsx b/ui/packages/components/src/custom/record-list-face.tsx new file mode 100644 index 00000000000..f16b13c568f --- /dev/null +++ b/ui/packages/components/src/custom/record-list-face.tsx @@ -0,0 +1,156 @@ +/** + * 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. + */ + +/** + * What a list drawn beside an open record reads from the record pane around + * it (objectstack#27545): the face it draws at its narrowed width, and which of + * its rows is the record the pane shows. + * + * Both are published by `NavigationOverlay` around the `mainContent` it is + * handed (the list), so a list under no pane reads the defaults: the full + * face, and no row is the open one. + * + * The open row is a store, not a context value: the pane changes record on + * every row click and every `↑` / `↓` step, and a context value would re-render + * the whole list each time. Each row subscribes to its own answer instead, so + * a step re-renders the row it leaves and the row it reaches, never the list. + */ + +import * as React from 'react'; + +/** + * The face of a list a record pane has narrowed: + * + * - `full` — no pane beside it (closed, an overlay drawer, or maximized): the + * list draws as it always does; + * - `columns` — the pane is beside it and it is at least + * {@link RECORD_LIST_CARD_FACE_BELOW_PX} wide: a table of its first + * {@link RECORD_LIST_NARROW_COLUMN_COUNT} non-avatar view columns; + * - `cards` — narrower than that: one card row per record, its name and two + * chips. + */ +export type RecordListFace = 'full' | 'columns' | 'cards'; + +/** Below this width a list beside a record pane draws card rows. */ +export const RECORD_LIST_CARD_FACE_BELOW_PX = 440; + +/** How many view columns a narrowed list keeps in its `columns` face. */ +export const RECORD_LIST_NARROW_COLUMN_COUNT = 3; + +const RecordListFaceContext = React.createContext('full'); + +/** Publishes the face of the list below it. */ +export const RecordListFaceProvider = RecordListFaceContext.Provider; + +/** The face this list draws in — `full` under no record pane. */ +export function useRecordListFace(): RecordListFace { + return React.useContext(RecordListFaceContext); +} + +/** The face a list beside an open record pane takes at `widthPx`. */ +export function recordListFaceFor(widthPx: number): Exclude { + return widthPx < RECORD_LIST_CARD_FACE_BELOW_PX ? 'cards' : 'columns'; +} + +interface ActiveListRecordStore { + get(): string | null; + set(recordId: string | null): void; + subscribe(listener: () => void): () => void; +} + +function createActiveListRecordStore(initial: string | null): ActiveListRecordStore { + let current = initial; + const listeners = new Set<() => void>(); + return { + get: () => current, + set: (recordId) => { + if (recordId === current) return; + current = recordId; + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +const ActiveListRecordContext = React.createContext(null); + +const NO_SUBSCRIPTION = () => () => {}; + +/** A list row's record id as the pane names it: `id`, else `_id`, as text. */ +export function listRecordIdOf(row: unknown): string | null { + const record = row as { id?: unknown; _id?: unknown } | null | undefined; + const id = record?.id ?? record?._id; + return (typeof id === 'string' && id !== '') || typeof id === 'number' ? String(id) : null; +} + +/** + * Publishes which row of the list below is the open record. Changing + * `recordId` notifies the rows that subscribed through + * {@link useIsActiveListRecord}; it re-renders nothing else below. + */ +export function ActiveListRecordProvider({ + recordId, + children, +}: { + recordId: string | null | undefined; + children: React.ReactNode; +}) { + const [store] = React.useState(() => createActiveListRecordStore(recordId ?? null)); + // After the commit, never during render: notifying a subscribed row while + // this provider renders would update another component mid-render. + React.useLayoutEffect(() => { + store.set(recordId ?? null); + }, [store, recordId]); + return {children}; +} + +/** Whether the row of `recordId` is the record the pane beside the list shows. */ +export function useIsActiveListRecord(recordId: string | null): boolean { + const store = React.useContext(ActiveListRecordContext); + return React.useSyncExternalStore( + store ? store.subscribe : NO_SUBSCRIPTION, + () => !!store && recordId !== null && store.get() === recordId, + () => false, + ); +} + +/** + * The open row's DOM side: once its row becomes the open record — a row click, + * a step, a deep link — it is scrolled into view, by the least distance. + */ +export function useScrollActiveListRecordIntoView(active: boolean) { + const ref = React.useRef(null); + React.useLayoutEffect(() => { + if (!active) return; + const el = ref.current; + if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' }); + }, [active]); + return ref; +} + +/** + * One list row's subscription to the open record: `children` draws the row, + * told whether it is the open one and handed the ref that scrolls it into + * view. Only this component re-renders when its answer changes. + */ +export function ActiveListRow({ + recordId, + children, +}: { + recordId: string | null; + children: (active: boolean, ref: React.RefObject) => React.ReactNode; +}) { + const active = useIsActiveListRecord(recordId); + const ref = useScrollActiveListRecordIntoView(active); + return <>{children(active, ref)}; +} diff --git a/ui/packages/components/src/renderers/complex/data-table.tsx b/ui/packages/components/src/renderers/complex/data-table.tsx index 2f5b4c2b0a7..968dc9ebc58 100644 --- a/ui/packages/components/src/renderers/complex/data-table.tsx +++ b/ui/packages/components/src/renderers/complex/data-table.tsx @@ -13,6 +13,7 @@ import { resolveIcon } from '../action/resolve-icon'; import { hasDeclaredVisibilityGate } from '../action/visibility-gate'; import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring'; import { useHorizontalOverflow, HOVER_SCROLLBAR_CLASSES } from '../../custom/use-horizontal-overflow'; +import { ActiveListRow, listRecordIdOf } from '../../custom/record-list-face'; import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic'; import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, hasDeclaredPredicate, formatDateTime, fromDateTimeInputValue, getSortValue, isEmptyValue, isImpossibleStoredDay, toDateInputValue, toDateTimeInputValue } from '@object-ui/core'; import type { DataTableSchema, TableColumn, TableSortItem, TableColumnType } from '@object-ui/types'; @@ -3454,8 +3455,16 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const rowErrored = erroredRows.has(rowKey); return ( - + {(isActiveRecord, activeRecordRef) => ( + { "[--os-row-tint:transparent] hover:[--os-row-tint:transparent] data-[state=selected]:[--os-row-tint:transparent]", "hover:bg-muted/40", "data-[state=selected]:bg-primary/5 data-[state=selected]:hover:bg-primary/10", + // The open record's row, frozen cells included. + "data-[active-record]:bg-primary/10 data-[active-record]:[--os-row-tint:color-mix(in_oklab,var(--color-primary)_10%,transparent)]", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", schema.onRowClick && "cursor-pointer", rowHasChanges && !rowErrored && "bg-amber-50 dark:bg-amber-950/20", @@ -3998,6 +4009,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { )} {addColumnEnabled && } + )} + ); })} {/* Add record row (Airtable-style) */} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2c9a18a3400..6613500bf4f 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1258,6 +1258,8 @@ const ar = { removeFromFavorites: "إزالة من المفضلة", previousRecord: "السجل السابق", nextRecord: "السجل التالي", + maximize: "تكبير", + restoreSize: "استعادة الحجم", recordOf: "{{current}} من {{total}}", recordNotFound: "لم يتم العثور على السجل", recordNotFoundDescription: "السجل الذي تبحث عنه غير موجود أو ربما تم حذفه.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 1d4852d5e3a..633e7028d74 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1132,6 +1132,8 @@ const de = { removeFromFavorites: "Aus Favoriten entfernen", previousRecord: "Vorheriger Datensatz", nextRecord: "Nächster Datensatz", + maximize: "Maximieren", + restoreSize: "Größe wiederherstellen", recordOf: "{{current}} von {{total}}", recordNotFound: "Datensatz nicht gefunden", recordNotFoundDescription: "Der gesuchte Datensatz existiert nicht oder wurde möglicherweise gelöscht.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 0618a93e6fb..413548d6ac0 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1477,6 +1477,8 @@ const en = { removeFromFavorites: 'Remove from favorites', previousRecord: 'Previous record', nextRecord: 'Next record', + maximize: 'Maximize', + restoreSize: 'Restore size', recordOf: '{{current}} of {{total}}', recordNotFound: 'Record not found', recordNotFoundDescription: 'The record you are looking for does not exist or may have been deleted.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index d83075e2e62..516b657c2f9 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1165,6 +1165,8 @@ const es = { removeFromFavorites: "Quitar de favoritos", previousRecord: "Registro anterior", nextRecord: "Siguiente registro", + maximize: "Maximizar", + restoreSize: "Restaurar tamaño", recordOf: "{{current}} de {{total}}", recordNotFound: "Registro no encontrado", recordNotFoundDescription: "El registro que busca no existe o puede haber sido eliminado.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index aa3bbe0b7f0..de866710980 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1162,6 +1162,8 @@ const fr = { removeFromFavorites: "Retirer des favoris", previousRecord: "Enregistrement précédent", nextRecord: "Enregistrement suivant", + maximize: "Agrandir", + restoreSize: "Restaurer la taille", recordOf: "{{current}} sur {{total}}", recordNotFound: "Enregistrement introuvable", recordNotFoundDescription: "L'enregistrement que vous recherchez n'existe pas ou a peut-être été supprimé.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 8a279cbc577..076365710a1 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1132,6 +1132,8 @@ const ja = { removeFromFavorites: "お気に入りから削除", previousRecord: "前のレコード", nextRecord: "次のレコード", + maximize: "最大化", + restoreSize: "元のサイズに戻す", recordOf: "{{current}} / {{total}}", recordNotFound: "レコードが見つかりません", recordNotFoundDescription: "お探しのレコードは存在しないか、削除された可能性があります。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index dac66918156..233da12cbb0 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1132,6 +1132,8 @@ const ko = { removeFromFavorites: "즐겨찾기에서 제거", previousRecord: "이전 레코드", nextRecord: "다음 레코드", + maximize: "최대화", + restoreSize: "원래 크기로", recordOf: "{{current}} / {{total}}", recordNotFound: "레코드를 찾을 수 없음", recordNotFoundDescription: "찾으시는 레코드가 존재하지 않거나 삭제되었을 수 있습니다.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index def65c2b726..db58b94c44f 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1162,6 +1162,8 @@ const pt = { removeFromFavorites: "Remover dos favoritos", previousRecord: "Registro anterior", nextRecord: "Próximo registro", + maximize: "Maximizar", + restoreSize: "Restaurar tamanho", recordOf: "{{current}} de {{total}}", recordNotFound: "Registro não encontrado", recordNotFoundDescription: "O registro que você está procurando não existe ou pode ter sido excluído.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 13363f2483d..e462e5eee1e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1199,6 +1199,8 @@ const ru = { removeFromFavorites: "Удалить из избранного", previousRecord: "Предыдущая запись", nextRecord: "Следующая запись", + maximize: "Развернуть", + restoreSize: "Восстановить размер", recordOf: "{{current}} из {{total}}", recordNotFound: "Запись не найдена", recordNotFoundDescription: "Запись, которую вы ищете, не существует или была удалена.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index cfe6b9f64f2..69c53db7fed 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1211,6 +1211,8 @@ const zh = { removeFromFavorites: '从收藏中移除', previousRecord: '上一条记录', nextRecord: '下一条记录', + maximize: '最大化', + restoreSize: '还原', recordOf: '第 {{current}} 条,共 {{total}} 条', recordNotFound: '未找到记录', recordNotFoundDescription: '您查找的记录不存在或已被删除。', diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx index 404bb2f394d..ec089b15cfc 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx @@ -54,22 +54,19 @@ import { ObjectCalendar } from './ObjectCalendar'; * * ⭐ objectui#9299 moved WHERE this is expressed without moving WHAT it resolves * to. It used to be an inline `max-width` on the drawer panel, spelled exactly. - * The shared shell publishes an authored width as a FLOOR on the `--ov-w` - * custom property — `max(WIDTH, min(60vw, 880px))` — and for this value that - * expression reduces to `min(960px, 60vw)` at every viewport: - * - * 60vw <= 880px -> max(60vw, 60vw) = 60vw = min(960px, 60vw) - * 880px < 60vw <= 960px-> max(60vw, 880px) = 60vw = min(960px, 60vw) - * 60vw > 960px -> max(960px, 880px) = 960px = min(960px, 60vw) - * - * so the pixels are unchanged — which is what objectui#6584 ruled must hold + * The shared shell publishes an authored width on the `--ov-w` custom + * property. Until objectstack#27545 it published it as a FLOOR — + * `max(WIDTH, min(60vw, 880px))`, which for this value reduced to + * `min(960px, 60vw)` at every viewport; that card retired the floor (the + * record pane is 720 px and the reader drags it), so the property now carries + * the value as authored and the pixels are unchanged — which is what objectui#6584 ruled must hold * across all four surfaces. ⛔ The literal is NOT retyped here: it lives in one * place, `RECORD_OVERLAY_DEFAULT_WIDTH`, and is imported. */ -const EXPECTED_WIDTH = `max(${RECORD_OVERLAY_DEFAULT_WIDTH}, min(60vw, 880px))`; +const EXPECTED_WIDTH = RECORD_OVERLAY_DEFAULT_WIDTH; -/** What an explicitly authored `720px` resolves to under the same floor. */ -const EXPECTED_AUTHORED_WIDTH = 'max(720px, min(60vw, 880px))'; +/** What an explicitly authored `720px` resolves to: itself (objectstack#27545). */ +const EXPECTED_AUTHORED_WIDTH = '720px'; // objectui#9299: the width now crosses into the shared `NavigationOverlay`; // `RecordDetailDrawer` is no longer on this path at all. Record what the @@ -246,12 +243,9 @@ describe('calendar drawer width with no declared `navigation` (objectui#6303)', it('an authored `navigation.width` still reaches the drawer unchanged', async () => { await openDrawer({ mode: 'drawer', width: '720px' }); expect(overlayProps.width).toBe('720px'); - // ⚠️ objectui#9299 consequence, pinned rather than left to be discovered: - // the shared shell treats an authored width as a FLOOR, so an authored - // value below `min(60vw, 880px)` widens to it. That is the policy - // `ObjectGrid` and `ObjectTree` have always had; the three drawer-only - // renderers adopt it along with the shell. The authored value still reaches - // the shell unchanged — that is the assertion above. + // objectui#9299 made the shell treat an authored width as a FLOOR + // (`min(60vw, 880px)`); objectstack#27545 retired the floor, so the + // authored value is also what the shell draws. expect(readPanelWidth()).toBe(EXPECTED_AUTHORED_WIDTH); }); }); diff --git a/ui/packages/plugin-calendar/src/__tests__/calendarNavigationMembers-8652.test.tsx b/ui/packages/plugin-calendar/src/__tests__/calendarNavigationMembers-8652.test.tsx index 30e14943060..877b0b81186 100644 --- a/ui/packages/plugin-calendar/src/__tests__/calendarNavigationMembers-8652.test.tsx +++ b/ui/packages/plugin-calendar/src/__tests__/calendarNavigationMembers-8652.test.tsx @@ -152,8 +152,11 @@ function panelWidth(): string { return panel!.style.getPropertyValue('--ov-w').trim(); } -/** What the shell makes of an authored width: the authored value as a FLOOR. */ -const withFloor = (css: string) => `max(${css}, min(60vw, 880px))`; +/** + * What the shell makes of an authored width: the authored value itself. It was + * a FLOOR (`max(WIDTH, min(60vw, 880px))`) until objectstack#27545 retired it. + */ +const asDrawn = (css: string) => css; beforeEach(() => { // A drag-resized width persisted in localStorage would mask every width row. @@ -237,19 +240,19 @@ describe('object-calendar `navigation` members decide the event click (objectui# it('`size` reaches the overlay through the bucket table', async () => { await clickEvent({ mode: 'drawer', size: 'lg' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('min(92vw, 960px)')); + expect(panelWidth()).toBe(asDrawn('min(92vw, 960px)')); }); it('a deprecated `width` WINS over `size` authored beside it', async () => { await clickEvent({ mode: 'drawer', size: 'sm', width: '720px' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('720px')); + expect(panelWidth()).toBe(asDrawn('720px')); }); it('`size: "auto"` resolves to NEITHER member and lands on the calendar default', async () => { await clickEvent({ mode: 'drawer', size: 'auto' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor(RECORD_OVERLAY_DEFAULT_WIDTH)); + expect(panelWidth()).toBe(asDrawn(RECORD_OVERLAY_DEFAULT_WIDTH)); }); it('a parent view\'s `onRowClick` takes the click in the overlay mode too, and no drawer opens (objectstack#27057)', async () => { diff --git a/ui/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx b/ui/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx index 98104572126..06931324408 100644 --- a/ui/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx +++ b/ui/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx @@ -34,22 +34,19 @@ import { ObjectGantt } from './ObjectGantt'; * * ⭐ objectui#9299 moved WHERE this is expressed without moving WHAT it resolves * to. It used to be an inline `max-width` on the drawer panel, spelled exactly. - * The shared shell publishes an authored width as a FLOOR on the `--ov-w` - * custom property — `max(WIDTH, min(60vw, 880px))` — and for this value that - * expression reduces to `min(960px, 60vw)` at every viewport: - * - * 60vw <= 880px -> max(60vw, 60vw) = 60vw = min(960px, 60vw) - * 880px < 60vw <= 960px-> max(60vw, 880px) = 60vw = min(960px, 60vw) - * 60vw > 960px -> max(960px, 880px) = 960px = min(960px, 60vw) - * - * so the pixels are unchanged — which is what objectui#6584 ruled must hold + * The shared shell publishes an authored width on the `--ov-w` custom + * property. Until objectstack#27545 it published it as a FLOOR — + * `max(WIDTH, min(60vw, 880px))`, which for this value reduced to + * `min(960px, 60vw)` at every viewport; that card retired the floor (the + * record pane is 720 px and the reader drags it), so the property now carries + * the value as authored and the pixels are unchanged — which is what objectui#6584 ruled must hold * across all four surfaces. ⛔ The literal is NOT retyped here: it lives in one * place, `RECORD_OVERLAY_DEFAULT_WIDTH`, and is imported. */ -const EXPECTED_WIDTH = `max(${RECORD_OVERLAY_DEFAULT_WIDTH}, min(60vw, 880px))`; +const EXPECTED_WIDTH = RECORD_OVERLAY_DEFAULT_WIDTH; -/** What an explicitly authored `720px` resolves to under the same floor. */ -const EXPECTED_AUTHORED_WIDTH = 'max(720px, min(60vw, 880px))'; +/** What an explicitly authored `720px` resolves to: itself (objectstack#27545). */ +const EXPECTED_AUTHORED_WIDTH = '720px'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -229,11 +226,9 @@ describe('gantt drawer width with no declared `navigation`', () => { fireEvent.click(screen.getByTestId('gv-view-1')); await waitFor(() => expect(overlayProps).not.toBeNull()); expect(overlayProps.width).toBe('720px'); - // ⚠️ objectui#9299 consequence, pinned rather than left to be discovered: - // the shared shell treats an authored width as a FLOOR, so an authored - // value below `min(60vw, 880px)` widens to it. That is the policy - // `ObjectGrid` and `ObjectTree` have always had; the three drawer-only - // renderers adopt it along with the shell. + // objectui#9299 made the shell treat an authored width as a FLOOR + // (`min(60vw, 880px)`); objectstack#27545 retired the floor, so the + // authored value is also what the shell draws. const panel = document.querySelector('[role="dialog"]') as HTMLElement | null; expect(panel!.style.getPropertyValue('--ov-w').trim()).toBe(EXPECTED_AUTHORED_WIDTH); }); diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index ad14e8cf5cf..06ab26839d4 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -56,6 +56,7 @@ import { DataEmptyState, EmptyStateLink, resolveIcon, HorizontalOverflowBox, toast, + useRecordListFace, ActiveListRow, listRecordIdOf, RECORD_LIST_NARROW_COLUMN_COUNT, } from '@object-ui/components'; import { resolveConditionalFormatting, declaredNameField, buildExportFileName, resolveAffordance, type SchemaLike, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, filterRefusalSubject, FilterOperatorError, resolveRecordSourceConfig, resolveRecordSourceObjectName, partitionRowsByPredicate } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; @@ -147,6 +148,32 @@ export function parseSchemaSort(sort: unknown): TableSortItem[] { return items; } +/** + * Whether a column draws an avatar: by its own type, or by its field's + * declared type (a column's `type` is only the type its editor needs). + */ +function isAvatarColumn(column: { accessorKey?: string; type?: string }, fieldTypeOf: (key: string) => unknown): boolean { + return column.type === 'avatar' || (!!column.accessorKey && fieldTypeOf(column.accessorKey) === 'avatar'); +} + +/** + * The columns of a list a record pane has narrowed to its `columns` face + * (objectstack#27545): its first {@link RECORD_LIST_NARROW_COLUMN_COUNT} view + * columns that are not avatars, in the view's order, and its row actions. + */ +function narrowListColumns( + columns: readonly C[], + fieldTypeOf: (key: string) => unknown, +): C[] { + let kept = 0; + return columns.filter((column) => { + if (column.accessorKey === '_actions') return true; + if (isAvatarColumn(column, fieldTypeOf) || kept >= RECORD_LIST_NARROW_COLUMN_COUNT) return false; + kept += 1; + return true; + }); +} + /** * Props of the `ObjectGrid` React component. * @@ -661,6 +688,9 @@ export const ObjectGrid: React.FC = ({ const metadata = useMetadata(); const [objectSchema, setObjectSchema] = useState(null); const [useCardView, setUseCardView] = useState(false); + // objectstack#27545 — the face a record pane beside this list narrowed it + // to: `full` under no pane. + const listFace = useRecordListFace(); const [refreshKey, setRefreshKey] = useState(0); // Optimistic concurrency for the default inline-edit saves (objectui#12292). // Per record, the version this grid's last landed write returned — see @@ -775,6 +805,8 @@ export const ObjectGrid: React.FC = ({ */ const objectFields: Record | undefined = objectSchema?.fields ?? hostObjectFields ?? undefined; + /** A field's declared type, for the narrowed faces' avatar rule (objectstack#27545). */ + const fieldTypeOf = (key: string): unknown => objectFields?.[key]?.type; /** * Are this grid's OBJECT field types still unknown (objectui#10657, which * folded objectui#10706)? True while an object definition is expected (an @@ -2490,7 +2522,7 @@ export const ObjectGrid: React.FC = ({ // and the whole block failed to render. caption: resolveInlineI18nLabel(schema.label, displayLocale) || resolveInlineI18nLabel(schema.title, displayLocale), - columns: tableColumns, + columns: listFace === 'columns' ? narrowListColumns(tableColumns, fieldTypeOf) : tableColumns, data, pagination: paginationEnabled, pageSize: manualPaginationOn ? manualPageSize : pageSize, @@ -3039,8 +3071,8 @@ export const ObjectGrid: React.FC = ({ // views: the plain list below, and a grouped list on a phone (the grouped // `gridContent`), which draws each group's band followed by its rows as // these same cards rather than keeping the desktop table. - const buildMobileCardRenderer = () => { - const displayColumns = generateColumns().filter((c) => c.accessorKey !== '_actions'); + const buildMobileCardRenderer = ({ compact = false }: { compact?: boolean } = {}) => { + const displayColumns = generateColumns().filter((c) => c.accessorKey !== '_actions' && !(compact && isAvatarColumn(c, fieldTypeOf))); // Build a lookup of column metadata for smart rendering const colMap = new Map(); @@ -3155,6 +3187,61 @@ export const ObjectGrid: React.FC = ({ return { label: /^[a-z0-9]+(_[a-z0-9]+)+$/.test(str) ? humanizeLabel(str) : str }; }; + // objectstack#27545 — the card row of a list a record pane narrowed below + // its columns face: the name and two chips, the open record highlighted + // and kept in view as the pane steps. + if (compact) { + const chipColumns = bodyColumns.slice(0, 2); + return (row: any, idx: number) => { + const recordId = listRecordIdOf(row); + return ( + key={recordId ?? idx} recordId={recordId}> + {(active, activeRef) => ( +
navigation.handleClick(row, e)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget || (e.key !== 'Enter' && e.key !== ' ')) return; + e.preventDefault(); + navigation.handleClick(row); + }} + > + {titleKey !== undefined && ( +
+ {isMaskedCardColumn(titleKey) + ? (titleCol ? titleCol.cell?.(row[titleKey], row) : ) + : (coerceToSafeValue(row[titleKey]) ?? '—')} + {rowAdornment ? {rowAdornment(row)} : null} +
+ )} +
+ {chipColumns.map((col: any) => { + const val = row[col.accessorKey]; + if ((val == null || val === '') && !isMaskedCardColumn(col.accessorKey)) return null; + return ( + + {col.cell ? col.cell(val, row) : String(coerceToSafeValue(val) ?? '')} + + ); + })} +
+
+ )} + + ); + }; + } + return (row: any, idx: number) => { // Collect secondary fields (skip the title column) const secondaryCols = bodyColumns.slice(0, 4); @@ -3337,8 +3424,10 @@ export const ObjectGrid: React.FC = ({ }; }; - if (useCardView && data.length > 0 && !isGrouped) { - const renderMobileCard = buildMobileCardRenderer(); + // A phone draws its own cards; a list a record pane narrowed draws card rows. + const cardFace = useCardView || listFace === 'cards'; + if (cardFace && data.length > 0 && !isGrouped) { + const renderMobileCard = buildMobileCardRenderer({ compact: !useCardView }); // objectui#13134 — the card list scrolls in a box of its own, as the // table does in its own body. Its host bounds it (`ListView`'s view area @@ -3713,7 +3802,7 @@ export const ObjectGrid: React.FC = ({ // server-grouped group's pager), in one vertical scroller like the plain // card list's. A band keeps its toggle and count, `groupedPager` still // pages whole groups, and the desktop table's columns never reach a phone. - const gridContent = isGrouped && useCardView ? ( + const gridContent = isGrouped && cardFace ? (
= ({ data-testid="grouped-grid-card-scroller" > {(() => { - const renderMobileCard = buildMobileCardRenderer(); + const renderMobileCard = buildMobileCardRenderer({ compact: !useCardView }); return groupBandsOf(pagedGroups).map((group) => (
{group.band} diff --git a/ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordPaneFace-27545.test.tsx b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordPaneFace-27545.test.tsx new file mode 100644 index 00000000000..fc0665dc41f --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordPaneFace-27545.test.tsx @@ -0,0 +1,120 @@ +/** + * 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#27545, Target 1 — a list beside an open record stays readable. + * + * The record pane publishes the face it narrowed the list to + * (`RecordListFaceProvider`): at ≥ 440 px the grid keeps its first three + * view columns that are not avatars (and its row actions); narrower, it draws + * one card row per record — the name and two chips — that opens the record + * like a table row. Under no pane (`full`) every column is drawn. The row of + * the open record is marked in both faces. + */ +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { RecordListFaceProvider, ActiveListRecordProvider, type RecordListFace } from '@object-ui/components'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const OBJECT = 'crm_lead'; +const OBJECT_FIELDS = { + photo: { name: 'photo', type: 'avatar', label: 'Photo' }, + name: { name: 'name', type: 'text', label: 'Lead Name' }, + company: { name: 'company', type: 'text', label: 'Company' }, + source: { name: 'source', type: 'text', label: 'Source' }, + owner: { name: 'owner', type: 'text', label: 'Owner' }, + city: { name: 'city', type: 'text', label: 'City' }, +}; +const ROWS = [ + { id: 'l1', photo: null, name: 'Fabrikam', company: 'Fabrikam Inc', source: 'Web', owner: 'Ada', city: 'Oslo' }, + { id: 'l2', photo: null, name: 'Contoso', company: 'Contoso Ltd', source: 'Event', owner: 'Bo', city: 'Rome' }, +]; + +const dataSource = { + find: async () => ({ data: ROWS, total: ROWS.length }), + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => true, + getObjectSchema: async () => ({ name: OBJECT, fields: OBJECT_FIELDS }), +}; + +function mount(face: RecordListFace, openRecordId: string | null = null, onRowClick = vi.fn()) { + render( + + + + + + + , + ); + return { onRowClick }; +} + +/** The view columns' headers: the leading row-number / checkbox cell is not one. */ +const headerTexts = () => + screen.getAllByRole('columnheader').map((h) => (h.textContent ?? '').trim()).filter((text) => text !== '' && text !== '#'); + +describe('the grid beside a record pane (objectstack#27545)', () => { + it('`columns`: the first three view columns that are not avatars, in the view\'s order', async () => { + mount('columns'); + await waitFor(() => expect(screen.getByText('Fabrikam')).toBeInTheDocument()); + expect(headerTexts()).toEqual(['Lead Name', 'Company', 'Source']); + expect(screen.queryByText('Oslo')).toBeNull(); + }); + + it('CONTROL — `full` (no pane): every view column is drawn', async () => { + mount('full'); + await waitFor(() => expect(screen.getByText('Fabrikam')).toBeInTheDocument()); + const headers = headerTexts(); + expect(headers).toEqual(expect.arrayContaining(['Photo', 'Lead Name', 'Company', 'Source', 'Owner', 'City'])); + }); + + it('`cards`: one card row per record — the name and two chips — that opens the record', async () => { + const { onRowClick } = mount('cards'); + await waitFor(() => expect(screen.getAllByTestId('grid-compact-card')).toHaveLength(2)); + expect(screen.queryByRole('columnheader')).toBeNull(); + const [first] = screen.getAllByTestId('grid-compact-card'); + expect(first.querySelector('[data-testid="grid-card-title"]')?.textContent).toContain('Fabrikam'); + const chips = Array.from(first.querySelectorAll('[data-testid="grid-compact-card-chip"]')).map((c) => c.textContent); + expect(chips).toEqual(['Fabrikam Inc', 'Web']); + fireEvent.click(first); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'l1' }); + }); + + it('marks the open record\'s row in both faces', async () => { + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {}); + mount('columns', 'l2'); + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()); + expect(screen.getByText('Contoso').closest('tr')?.hasAttribute('data-active-record')).toBe(true); + expect(screen.getByText('Fabrikam').closest('tr')?.hasAttribute('data-active-record')).toBe(false); + cleanup(); + mount('cards', 'l2'); + await waitFor(() => expect(screen.getAllByTestId('grid-compact-card')).toHaveLength(2)); + const cards = screen.getAllByTestId('grid-compact-card'); + expect(cards.map((c) => c.hasAttribute('data-active-record'))).toEqual([false, true]); + }); +}); diff --git a/ui/packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx b/ui/packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx index d18aaaea589..3e1a89aaa09 100644 --- a/ui/packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx @@ -127,8 +127,11 @@ function panelWidth(): string { return panel!.style.getPropertyValue('--ov-w').trim(); } -/** What the shell makes of an authored width: the authored value as a FLOOR. */ -const withFloor = (css: string) => `max(${css}, min(60vw, 880px))`; +/** + * What the shell makes of an authored width: the authored value itself. It was + * a FLOOR (`max(WIDTH, min(60vw, 880px))`) until objectstack#27545 retired it. + */ +const asDrawn = (css: string) => css; beforeEach(() => { try { window.localStorage.clear(); } catch { /* private mode */ } @@ -240,10 +243,10 @@ describe('object-grid `navigation` members decide the row click (objectui#8071)' it('`size` reaches the overlay through the bucket table', async () => { // `lg` is a NAME on the authoring side and a CSS length by the time it - // reaches the shell, which then treats it as a floor. + // reaches the shell, which draws it as given. await clickRow({ mode: 'drawer', size: 'lg' }); await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('min(92vw, 960px)')); + expect(panelWidth()).toBe(asDrawn('min(92vw, 960px)')); }); it('a deprecated `width` WINS over `size` authored beside it', async () => { @@ -251,7 +254,7 @@ describe('object-grid `navigation` members decide the row click (objectui#8071)' // explicit 720px. The deprecated spelling is the one that lands. await clickRow({ mode: 'drawer', size: 'sm', width: '720px' }); await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('720px')); + expect(panelWidth()).toBe(asDrawn('720px')); }); it('`size: "auto"` resolves to NEITHER member and lands on the block default', async () => { @@ -260,12 +263,12 @@ describe('object-grid `navigation` members decide the row click (objectui#8071)' // same width an unauthored overlay gets, which is the point. await clickRow({ mode: 'drawer', size: 'auto' }); await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()); - expect(panelWidth()).toBe(withFloor(RECORD_OVERLAY_DEFAULT_WIDTH)); + expect(panelWidth()).toBe(asDrawn(RECORD_OVERLAY_DEFAULT_WIDTH)); cleanup(); try { window.localStorage.clear(); } catch { /* private mode */ } await clickRow({ mode: 'drawer' }); await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()); - expect(panelWidth()).toBe(withFloor(RECORD_OVERLAY_DEFAULT_WIDTH)); + expect(panelWidth()).toBe(asDrawn(RECORD_OVERLAY_DEFAULT_WIDTH)); }); }); diff --git a/ui/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx b/ui/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx index 0b6e62ba9a4..a3170f697b5 100644 --- a/ui/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx +++ b/ui/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx @@ -61,22 +61,19 @@ import './KanbanImpl'; * * ⭐ objectui#9299 moved WHERE this is expressed without moving WHAT it resolves * to. It used to be an inline `max-width` on the drawer panel, spelled exactly. - * The shared shell publishes an authored width as a FLOOR on the `--ov-w` - * custom property — `max(WIDTH, min(60vw, 880px))` — and for this value that - * expression reduces to `min(960px, 60vw)` at every viewport: - * - * 60vw <= 880px -> max(60vw, 60vw) = 60vw = min(960px, 60vw) - * 880px < 60vw <= 960px-> max(60vw, 880px) = 60vw = min(960px, 60vw) - * 60vw > 960px -> max(960px, 880px) = 960px = min(960px, 60vw) - * - * so the pixels are unchanged — which is what objectui#6584 ruled must hold + * The shared shell publishes an authored width on the `--ov-w` custom + * property. Until objectstack#27545 it published it as a FLOOR — + * `max(WIDTH, min(60vw, 880px))`, which for this value reduced to + * `min(960px, 60vw)` at every viewport; that card retired the floor (the + * record pane is 720 px and the reader drags it), so the property now carries + * the value as authored and the pixels are unchanged — which is what objectui#6584 ruled must hold * across all four surfaces. ⛔ The literal is NOT retyped here: it lives in one * place, `RECORD_OVERLAY_DEFAULT_WIDTH`, and is imported. */ -const EXPECTED_WIDTH = `max(${RECORD_OVERLAY_DEFAULT_WIDTH}, min(60vw, 880px))`; +const EXPECTED_WIDTH = RECORD_OVERLAY_DEFAULT_WIDTH; -/** What an explicitly authored `720px` resolves to under the same floor. */ -const EXPECTED_AUTHORED_WIDTH = 'max(720px, min(60vw, 880px))'; +/** What an explicitly authored `720px` resolves to: itself (objectstack#27545). */ +const EXPECTED_AUTHORED_WIDTH = '720px'; // Record the props ObjectKanban hands down, then delegate to the REAL drawer so // half 2 measures the actual resolution rather than a stub's idea of it. @@ -249,12 +246,9 @@ describe('kanban drawer width with no declared `navigation` (objectui#6303)', () it('an authored `navigation.width` still reaches the drawer unchanged', async () => { await openDrawer({ mode: 'drawer', width: '720px' }); expect(overlayProps.width).toBe('720px'); - // ⚠️ objectui#9299 consequence, pinned rather than left to be discovered: - // the shared shell treats an authored width as a FLOOR, so an authored - // value below `min(60vw, 880px)` widens to it. That is the policy - // `ObjectGrid` and `ObjectTree` have always had; the three drawer-only - // renderers adopt it along with the shell. The authored value still reaches - // the shell unchanged — that is the assertion above. + // objectui#9299 made the shell treat an authored width as a FLOOR + // (`min(60vw, 880px)`); objectstack#27545 retired the floor, so the + // authored value is also what the shell draws. expect(readPanelWidth()).toBe(EXPECTED_AUTHORED_WIDTH); }); }); diff --git a/ui/packages/plugin-kanban/src/__tests__/kanbanNavigationMembers-8652.test.tsx b/ui/packages/plugin-kanban/src/__tests__/kanbanNavigationMembers-8652.test.tsx index ad54f56af3e..9795e9fc510 100644 --- a/ui/packages/plugin-kanban/src/__tests__/kanbanNavigationMembers-8652.test.tsx +++ b/ui/packages/plugin-kanban/src/__tests__/kanbanNavigationMembers-8652.test.tsx @@ -136,8 +136,11 @@ function panelWidth(): string { return panel!.style.getPropertyValue('--ov-w').trim(); } -/** What the shell makes of an authored width: the authored value as a FLOOR. */ -const withFloor = (css: string) => `max(${css}, min(60vw, 880px))`; +/** + * What the shell makes of an authored width: the authored value itself. It was + * a FLOOR (`max(WIDTH, min(60vw, 880px))`) until objectstack#27545 retired it. + */ +const asDrawn = (css: string) => css; beforeEach(() => { // A drag-resized width persisted in localStorage would mask every width row. @@ -221,19 +224,19 @@ describe('object-kanban `navigation` members decide the card click (objectui#865 it('`size` reaches the overlay through the bucket table', async () => { await clickCard({ mode: 'drawer', size: 'lg' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('min(92vw, 960px)')); + expect(panelWidth()).toBe(asDrawn('min(92vw, 960px)')); }); it('a deprecated `width` WINS over `size` authored beside it', async () => { await clickCard({ mode: 'drawer', size: 'sm', width: '720px' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('720px')); + expect(panelWidth()).toBe(asDrawn('720px')); }); it('`size: "auto"` resolves to NEITHER member and lands on the board default', async () => { await clickCard({ mode: 'drawer', size: 'auto' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor(RECORD_OVERLAY_DEFAULT_WIDTH)); + expect(panelWidth()).toBe(asDrawn(RECORD_OVERLAY_DEFAULT_WIDTH)); }); it('a parent view\'s click handler OUTRANKS the whole key, overlay mode included', async () => { diff --git a/ui/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx b/ui/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx index 12010fec543..bf5cb8a531d 100644 --- a/ui/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx +++ b/ui/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx @@ -137,8 +137,11 @@ function panelWidth(): string { return panel!.style.getPropertyValue('--ov-w').trim(); } -/** What the shell makes of an authored width: the authored value as a FLOOR. */ -const withFloor = (css: string) => `max(${css}, min(60vw, 880px))`; +/** + * What the shell makes of an authored width: the authored value itself. It was + * a FLOOR (`max(WIDTH, min(60vw, 880px))`) until objectstack#27545 retired it. + */ +const asDrawn = (css: string) => css; beforeEach(() => { // A drag-resized width persisted in localStorage would mask every width row. @@ -205,13 +208,13 @@ describe('object-timeline `navigation` members decide the entry click (objectui# it('`size` reaches the overlay through the bucket table', async () => { await clickEntry({ mode: 'drawer', size: 'lg' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('min(92vw, 960px)')); + expect(panelWidth()).toBe(asDrawn('min(92vw, 960px)')); }); it('a deprecated `width` WINS over `size` authored beside it', async () => { await clickEntry({ mode: 'drawer', size: 'sm', width: '720px' }); await waitFor(() => expect(dialog()).not.toBeNull()); - expect(panelWidth()).toBe(withFloor('720px')); + expect(panelWidth()).toBe(asDrawn('720px')); }); it('`size: "auto"` resolves to NEITHER member and lands where a drawer with no size does', async () => { @@ -223,7 +226,7 @@ describe('object-timeline `navigation` members decide the entry click (objectui# await waitFor(() => expect(dialog()).not.toBeNull()); expect(panelWidth()).toBe(unsized); // Non-vacuity: the unsized width is not the `lg` bucket's. - expect(unsized).not.toBe(withFloor('min(92vw, 960px)')); + expect(unsized).not.toBe(asDrawn('min(92vw, 960px)')); }); it('a parent view\'s click handler OUTRANKS the whole key, overlay mode included', async () => {