diff --git a/.changeset/12703-compact-list-block.md b/.changeset/12703-compact-list-block.md new file mode 100644 index 0000000000..570a5505ff --- /dev/null +++ b/.changeset/12703-compact-list-block.md @@ -0,0 +1,15 @@ +--- +'@object-ui/components': minor +'@object-ui/plugin-list': minor +--- + +A list block in a page region narrower than half the page draws a compact list (objectui#12703). + +The page renderer measures each region against the page body and tells the region's blocks their posture: `compact` when the region is narrower than half the page (an `aside` or `sidebar` rail beside `main`), `full` otherwise. The measurement is the region's rendered width relative to the page, never the viewport, so the same `aside` is compact beside `main` and full once a narrow screen stacks it. Nothing is authored: `@objectstack/spec` declares no posture or `compact` key, and none is added. + +A `list-view` block in a compact region draws a header with its title and record count over two-line rows: the view's first visible column, with its second muted below. It draws no toolbar, no view tabs, no row-number column, no pager and no record-count bar. A click on a row opens the record the way a grid row does. A region half the page or wider, a region whose width is not known (server render, no `ResizeObserver`), and the object list page keep the full list unchanged. The compact list applies to a paged grid; a board, a calendar or a grouped grid keeps its own rendering. + +Published surface: + +- `@object-ui/components` exports `usePageRegionPosture()` and the type `PageRegionPosture` (`'compact' | 'full'`). The hook answers `null` outside a page region or before the width is known. +- `@object-ui/plugin-list`'s `ListViewProps` gains the optional host prop `compact`. It is a React prop, never a schema key; absent, the list renders as before. diff --git a/content/docs/guide/react-pages.md b/content/docs/guide/react-pages.md index 69b8717fc7..5e0fffff6d 100644 --- a/content/docs/guide/react-pages.md +++ b/content/docs/guide/react-pages.md @@ -359,6 +359,21 @@ by the build* — but with this tier's own primitive: lay out with the blocks' structured props (``, ``) and add CSS as a JSON `style` object. See *Styling*, above. +## List blocks in a narrow region + +Region pages (the schema-tree pages above, built from `regions[].components[]`) +size each block to the region it sits in. A `list-view` block in a region +narrower than half the page, such as an `aside` rail beside `main`, renders a +compact list: a header with the title and record count, then two-line rows +built from the view's first two visible columns. It renders no toolbar, no view +tabs and no row-number column. A region half the page or wider renders the full +list. + +The posture is measured from the region's rendered width, never authored, and +no page or block key sets it. `kind:'html'` and `kind:'react'` pages have no +regions, so their list blocks always render the full list. A "View all" link from +the compact list to the object list is a planned follow-up. + ## Related - [Slotted Pages](./slotted-pages.md) — `kind:'full'` / `kind:'slotted'` record pages. diff --git a/packages/components/README.md b/packages/components/README.md index 8d30c787cf..dd2263cd4a 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -140,6 +140,28 @@ function MyComponent() { - `tabs` - Tab navigation - `accordion` - Collapsible sections +#### Page region posture — `usePageRegionPosture()` + +The page renderer measures each region of a page against the page body, using +`ResizeObserver` on the region, not the viewport, and tells the region's blocks +how much room they have: + +```ts +import { usePageRegionPosture, type PageRegionPosture } from '@object-ui/components'; + +const posture: PageRegionPosture | null = usePageRegionPosture(); // 'compact' | 'full' | null +``` + +- `'compact'`: the region is narrower than `(page − 24) / 2`, that is, half + the page less the 24 px gap between regions side by side. A half-and-half + cell therefore stays `'full'`. +- `'full'`: the region is half the page or wider. +- `null`: outside a page region, or before the width is known (server render, + no `ResizeObserver`). Read `null` as `'full'`. + +The posture is measured, never authored, and no schema key sets it. The +`list-view` block in `@object-ui/plugin-list` reads it to draw a compact list. + ### Data Display - `table` - Data table - `list` - List view diff --git a/packages/components/src/__tests__/page-region-posture-12703.test.tsx b/packages/components/src/__tests__/page-region-posture-12703.test.tsx new file mode 100644 index 0000000000..3d5bfdcdcf --- /dev/null +++ b/packages/components/src/__tests__/page-region-posture-12703.test.tsx @@ -0,0 +1,138 @@ +/** + * 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. + * + * A page region tells its blocks their posture, measured against the page + * (objectui#12703): narrower than half the page ⇒ `compact`, otherwise `full`, + * and `null` wherever the width is not known. + * + * A DOM test has no layout, so the widths come from a stand-in + * `ResizeObserver` that reports a width per observed element: the page body + * (the element `[data-page-type] > div`) and each region's grid + * (`[data-region]`). A probe block registered here prints the posture it reads. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Module scope, not a hook: registers PageRenderer (AGENTS.md §测试纪律). +import '../renderers'; +import { pageRegionPostureFor, usePageRegionPosture } from '../renderers/layout/page-region-posture'; + +const PROBE = 'test:region-posture-probe-12703'; + +function PostureProbe({ schema }: { schema: { id?: string } }) { + const posture = usePageRegionPosture(); + return {posture ?? 'unknown'}; +} +ComponentRegistry.register(PROBE, PostureProbe as never, { namespace: 'test', label: 'Posture probe' }); + +const probe = (id: string) => ({ type: PROBE, id }); + +/** Installs a `ResizeObserver` that reports `widths` for the page body and each region. */ +function stubWidths(widths: { page: number; regions: Record }) { + class WidthReportingObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + const region = target.getAttribute('data-region'); + const width = region != null + ? widths.regions[region] + : target.matches('[data-page-type] > div') ? widths.page : undefined; + if (width === undefined) return; + this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as never); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', WidthReportingObserver); +} + +function renderPage(schema: unknown) { + return render( + + + , + ); +} + +const threeColumnPage = { + type: 'home', + template: 'three-column', + regions: [ + { name: 'main', components: [probe('in-main')] }, + { name: 'aside', width: 'medium', components: [probe('in-aside')] }, + ], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('pageRegionPostureFor (objectui#12703)', () => { + it('is compact below half the page, full at half or wider', () => { + // 1200 wide: "half" is (1200 - 24) / 2 = 588, the width of each cell of a + // half-and-half row. + expect(pageRegionPostureFor(320, 1200)).toBe('compact'); + expect(pageRegionPostureFor(587, 1200)).toBe('compact'); + expect(pageRegionPostureFor(588, 1200)).toBe('full'); + expect(pageRegionPostureFor(856, 1200)).toBe('full'); + expect(pageRegionPostureFor(1200, 1200)).toBe('full'); + }); + + it('is unknown when either width is unknown', () => { + expect(pageRegionPostureFor(0, 1200)).toBeNull(); + expect(pageRegionPostureFor(320, 0)).toBeNull(); + expect(pageRegionPostureFor(Number.NaN, 1200)).toBeNull(); + }); +}); + +describe('a page region publishes its measured posture to its blocks (objectui#12703)', () => { + it('the aside beside main is compact and main is full', async () => { + stubWidths({ page: 1200, regions: { main: 856, aside: 320 } }); + renderPage(threeColumnPage); + await waitFor(() => expect(screen.getByTestId('posture-in-aside').textContent).toBe('compact')); + expect(screen.getByTestId('posture-in-main').textContent).toBe('full'); + }); + + it('a stacked aside (as wide as the page) is full', async () => { + stubWidths({ page: 700, regions: { main: 700, aside: 700 } }); + renderPage(threeColumnPage); + await waitFor(() => expect(screen.getByTestId('posture-in-aside').textContent).toBe('full')); + }); + + it('a block nested in a container inside the region reads the region posture', async () => { + stubWidths({ page: 1200, regions: { main: 856, aside: 320 } }); + renderPage({ + ...threeColumnPage, + regions: [ + threeColumnPage.regions[0], + { name: 'aside', components: [{ type: 'page:card', id: 'card', properties: { title: 'Card', children: [probe('in-card')] } }] }, + ], + }); + await waitFor(() => expect(screen.getByTestId('posture-in-card').textContent).toBe('compact')); + }); + + it('no measurement (no ResizeObserver, no layout) leaves the posture unknown', () => { + vi.stubGlobal('ResizeObserver', undefined); + renderPage(threeColumnPage); + expect(screen.getByTestId('posture-in-aside').textContent).toBe('unknown'); + expect(screen.getByTestId('posture-in-main').textContent).toBe('unknown'); + }); + + it('a page with no regions gives its blocks no posture', () => { + stubWidths({ page: 1200, regions: {} }); + renderPage({ type: 'page', children: [probe('flat')] }); + expect(screen.getByTestId('posture-flat').textContent).toBe('unknown'); + }); + + it('a block outside any page reads no posture', () => { + render(); + expect(screen.getByTestId('posture-bare').textContent).toBe('unknown'); + }); +}); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 6a79610dad..bc5a3b99ec 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -75,6 +75,13 @@ export { isCustomRowActionVisible } from './renderers/complex/data-table'; export { pageBlockSpan, isPageRailRegion, PAGE_BLOCK_SPANS } from './renderers/layout/page-block-span'; export type { PageBlockSpan } from './renderers/layout/page-block-span'; +// The posture of the page region a block renders in, measured by the page +// renderer (objectui#12703): `compact` when the region is narrower than half +// the page. Exported for the blocks that read it in other packages — the +// `list-view` block in `@object-ui/plugin-list` draws a short list there. +export { usePageRegionPosture } from './renderers/layout/page-region-posture'; +export type { PageRegionPosture } from './renderers/layout/page-region-posture'; + // THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one // tokeniser and one rename map for the whole repo. // diff --git a/packages/components/src/renderers/layout/page-block-grid.tsx b/packages/components/src/renderers/layout/page-block-grid.tsx index 3e04180f56..63aff2912f 100644 --- a/packages/components/src/renderers/layout/page-block-grid.tsx +++ b/packages/components/src/renderers/layout/page-block-grid.tsx @@ -37,11 +37,13 @@ export const PageBlockGrid: React.FC<{ /** Maps a node to the schema `SchemaRenderer` renders (identity by default). */ toSchema?: (node: unknown) => unknown; attributes?: Record; -}> = ({ nodes, className, rail, keyPrefix = 'block', toSchema, attributes }) => { + /** The grid element, for a caller that measures it (the region's posture, objectui#12703). */ + gridRef?: React.Ref; +}> = ({ nodes, className, rail, keyPrefix = 'block', toSchema, attributes, gridRef }) => { const inRail = React.useContext(PageBlockRailContext); const isRail = !!rail || inRail; const grid = ( -
+
{nodes.map((node, idx) => { const n = node as { id?: unknown; type?: unknown } | null; const span = pageBlockSpan(n?.type, isRail); diff --git a/packages/components/src/renderers/layout/page-region-posture.tsx b/packages/components/src/renderers/layout/page-region-posture.tsx new file mode 100644 index 0000000000..761c974f5b --- /dev/null +++ b/packages/components/src/renderers/layout/page-region-posture.tsx @@ -0,0 +1,185 @@ +/** + * 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. + */ + +import * as React from 'react'; + +/** + * How a block in a page region should present itself, judged by the region's + * MEASURED width against the page's (objectui#12703). + * + * - `'compact'`: the region is narrower than half the page — the `aside` rail + * beside `main`, a sidebar, a third of a three-column body. A list block + * there draws a short list (title, count, two-line rows) instead of the + * object list's own chrome. + * - `'full'`: the region is half the page or wider. + * + * `null` — the answer outside any page region, and inside one whose width is + * not known yet (no layout: server render, jsdom, the first render before the + * measurement lands). A block reads `null` exactly as `'full'`: an unknown + * width keeps today's full rendering. + * + * ⭐ DERIVED, never authored. `@objectstack/spec` declares no posture or + * `compact` key on a page, a region or a block, and this adds none: the page + * renderer measures, and a block reads the answer through + * {@link usePageRegionPosture}. ⛔ Not a viewport breakpoint: a region's + * width relative to its page is what decides, so the same `aside` is compact + * on a wide screen and full once the layout stacks it under `main`. + */ +export type PageRegionPosture = 'compact' | 'full'; + +/** + * The gap the page templates put between regions laid side by side (`gap-6`). + * Two regions sharing a row half and half (the `dashboard` template's cells) + * are each `(page − gap) / 2` wide, which is "half the page", so the cut sits + * there and not at `page / 2`. + */ +const REGION_GAP_PX = 24; + +/** Sub-pixel slack, so a half-and-half region rounding down is still half. */ +const ROUNDING_SLACK_PX = 0.5; + +/** + * The posture a region `regionWidth` wide takes on a page `pageWidth` wide. + * `null` when either width is unknown (zero, negative or not a number): the + * caller then keeps the full rendering. + */ +export function pageRegionPostureFor(regionWidth: number, pageWidth: number): PageRegionPosture | null { + if (!(regionWidth > 0) || !(pageWidth > 0)) return null; + return regionWidth < (pageWidth - REGION_GAP_PX) / 2 - ROUNDING_SLACK_PX ? 'compact' : 'full'; +} + +/** + * The page body's width, as a store a region subscribes to rather than a + * context VALUE: a value that changed on every resize would re-render every + * region and the whole block tree under it, while a subscription re-renders a + * region only when its posture flips. + */ +export interface PageWidthStore { + get(): number; + set(width: number): void; + subscribe(listener: () => void): () => void; +} + +function createPageWidthStore(): PageWidthStore { + let width = 0; + const listeners = new Set<() => void>(); + return { + get: () => width, + set: (next) => { + if (next === width) return; + width = next; + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +const PageWidthContext = React.createContext(null); + +const PageRegionPostureContext = React.createContext(null); + +/** + * `useLayoutEffect` where there is a DOM, so the first measurement lands + * before the browser paints and a compact block does not flash the full list + * first; `useEffect` on the server, where a layout effect warns and there is + * nothing to measure anyway. + */ +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; + +/** + * Watches `element`'s width: once now (`getBoundingClientRect`), then on every + * resize. No `ResizeObserver` (an old browser, a test environment without + * one) ⇒ the one reading only. Returns the cleanup. + */ +function watchWidth(element: Element, onWidth: (width: number) => void): () => void { + onWidth(element.getBoundingClientRect().width); + if (typeof ResizeObserver === 'undefined') return () => {}; + const observer = new ResizeObserver((entries) => { + const width = entries[entries.length - 1]?.contentRect?.width; + if (typeof width === 'number') onWidth(width); + }); + observer.observe(element); + return () => observer.disconnect(); +} + +/** + * The page's width store, watching the page body `bodyRef` points at — the + * element the page's regions are laid out in. Called by `PageRenderer`, the + * component that RENDERS that element, and handed to {@link PageWidthProvider}. + * + * ⚠️ The watch has to run in the component that renders the body element, not + * in a provider nested inside it: React attaches a host element's ref after + * the layout effects of the components below it have run, so a provider + * inside the body would find the ref still empty. + */ +export function usePageWidthStore(bodyRef: React.RefObject): PageWidthStore { + // One store for the life of the page: a `useState` initializer, which React + // runs once, not a memo whose identity it may discard (AGENTS.md #10). + const [store] = React.useState(createPageWidthStore); + useIsomorphicLayoutEffect(() => { + const body = bodyRef.current; + if (!body) return undefined; + return watchWidth(body, (width) => store.set(width)); + }, [bodyRef, store]); + return store; +} + +/** Publishes the page's width store to every region under it. Rendered by `PageRenderer`. */ +export const PageWidthProvider: React.FC<{ + store: PageWidthStore; + children: React.ReactNode; +}> = ({ store, children }) => {children}; + +/** + * Measures the region `regionRef` points at against the page width published + * above it and tells the region's blocks their posture. Rendered by the page + * renderer's `RegionContent`. It re-renders when the posture flips and at no + * other resize, and its `children` element is the same one each time, so a + * flip reaches the blocks that read the posture and nothing else. + */ +export const PageRegionPostureProvider: React.FC<{ + regionRef: React.RefObject; + children: React.ReactNode; +}> = ({ regionRef, children }) => { + const store = React.useContext(PageWidthContext); + const [posture, setPosture] = React.useState(null); + useIsomorphicLayoutEffect(() => { + const region = regionRef.current; + if (!region || !store) return undefined; + let regionWidth = 0; + const judge = () => setPosture(pageRegionPostureFor(regionWidth, store.get())); + // Subscribed BEFORE the page measures: a child's layout effect runs ahead + // of its parent's, so the page body's first reading arrives through this + // subscription. + const unsubscribe = store.subscribe(judge); + const unwatch = watchWidth(region, (width) => { + regionWidth = width; + judge(); + }); + return () => { + unsubscribe(); + unwatch(); + }; + }, [regionRef, store]); + return {children}; +}; + +/** + * The posture of the page region this block renders in: `'compact'` when the + * region is narrower than half the page, `'full'` when it is not, `null` + * outside a page region or before the width is known — read `null` as + * `'full'`. Safe outside any provider. + */ +export function usePageRegionPosture(): PageRegionPosture | null { + return React.useContext(PageRegionPostureContext); +} diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index ec3696fcac..dad6a91a96 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -32,6 +32,7 @@ import { PRINT_PAGE_ATTRIBUTE, PRINT_RUNNING_ATTRIBUTE, buildPagePrintStylesheet import { cn } from '../../lib/utils'; import { PageBlockGrid } from './page-block-grid'; import { PageDescriptionContext } from './page-description-context'; +import { PageRegionPostureProvider, PageWidthProvider, usePageWidthStore } from './page-region-posture'; // --------------------------------------------------------------------------- // Helpers @@ -205,24 +206,35 @@ function pageHeaderOwnsTitle(schema: PageNodeSchema): boolean { * grid. A `rail` region (sidebar / aside) is a narrow column, where a fold by * viewport width would squeeze a tile to a sliver, so its blocks, and the * blocks inside its containers, stay full width (`./page-block-grid`). + * + * Each region also tells its blocks their posture (objectui#12703): the + * region's grid is measured against the page body `PageRenderer` publishes, + * and a region narrower than half the page is `compact` + * (`./page-region-posture`). A list block reads it to draw a short list + * instead of the object list's chrome. Measured, never authored, and judged + * on the region, not the viewport. */ const RegionContent: React.FC<{ region: PageNodeRegion; className?: string; rail?: boolean; }> = ({ region, className, rail }) => { + const gridRef = React.useRef(null); const components = region.components || []; if (components.length === 0) return null; return ( - toRenderableSchema(node as SchemaNode)} - attributes={{ 'data-region': region.name }} - /> + + toRenderableSchema(node as SchemaNode)} + attributes={{ 'data-region': region.name }} + gridRef={gridRef} + /> + ); }; @@ -673,6 +685,13 @@ export const PageRenderer: React.FC<{ const print = isPagePrintDeclaration(schema.print) ? schema.print : null; const printStylesheet = print ? buildPagePrintStylesheet(print) : null; + // The element the regions are laid out in, whose width each region's + // posture is judged against (objectui#12703): the max-width body, not the + // padded outer wrapper, so a centred body on a very wide screen is "the + // page" and its `main` is not mistaken for a narrow column. + const pageBodyRef = React.useRef(null); + const pageWidthStore = usePageWidthStore(pageBodyRef); + // Select the layout variant based on template or page type const layoutElement = useMemo(() => { // `PageNodeSchema['kind']` is the spec's own page-kind vocabulary, so this @@ -798,7 +817,7 @@ export const PageRenderer: React.FC<{ {printStylesheet && (