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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/12703-compact-list-block.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions content/docs/guide/react-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,21 @@ by the build* — but with this tier's own primitive: lay out with the blocks'
structured props (`<flex direction gap>`, `<grid columns>`) 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.
Expand Down
22 changes: 22 additions & 0 deletions packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions packages/components/src/__tests__/page-region-posture-12703.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <span data-testid={`posture-${schema.id}`}>{posture ?? 'unknown'}</span>;
}
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<string, number> }) {
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(
<ActionProvider>
<SchemaRenderer schema={schema as never} />
</ActionProvider>,
);
}

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(<SchemaRenderer schema={probe('bare') as never} />);
expect(screen.getByTestId('posture-bare').textContent).toBe('unknown');
});
});
7 changes: 7 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
6 changes: 4 additions & 2 deletions packages/components/src/renderers/layout/page-block-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>;
}> = ({ 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<HTMLDivElement>;
}> = ({ nodes, className, rail, keyPrefix = 'block', toSchema, attributes, gridRef }) => {
const inRail = React.useContext(PageBlockRailContext);
const isRail = !!rail || inRail;
const grid = (
<div className={cn('grid grid-cols-12 gap-4', className)} {...attributes}>
<div ref={gridRef} className={cn('grid grid-cols-12 gap-4', className)} {...attributes}>
{nodes.map((node, idx) => {
const n = node as { id?: unknown; type?: unknown } | null;
const span = pageBlockSpan(n?.type, isRail);
Expand Down
Loading
Loading