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
13 changes: 13 additions & 0 deletions .changeset/12624-page-block-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
---

App pages lay a region's blocks out on a 12-column grid, and the page description sits under its title (objectui#12624).

- **Block grid.** A page region's blocks used to stack one per row, so four metric tiles drew four full-width stripes. They now sit on a 12-column grid. A block's span comes from its kind, not from a key on the block: metric tiles (`element:number`, `statistic`, `object-metric` and the dashboard metric widgets) span 3, charts (`chart`, `object-chart`, `bar-chart`) span 6, and every other block spans 12. From 1280px up the spans apply as named; from 768px to 1279px tiles span 6 and charts 12; under 768px every block spans 12. So four tiles share one row at 1280px and take two rows at 1024px. A `sidebar` or `aside` region beside `main` keeps its blocks full width. On the `dashboard` template each region is still a cell of the region grid and lays its own blocks out the same way.
- **Container grid.** A `page:card` or `page:section` lays its list of children on the same grid. The container is one block of its region and spans 12. Its children take their kind's span and fold the same way, so a card holding four `object-metric` tiles (a Sales Home KPI card) shows them in one row at 1280px and two rows at 1024px. Children that are not tiles still stack full width. Inside a rail region they stay full width too.
- **Description under the title.** When a titled `page:header` owns the page's heading, the page drew its `description` above the regions, so above the title. The header now draws it under the title and subtitle. The page still has one `h1`.
- **Studio page designer.** Each block card in a region, and each child card of a `page:card` or `page:section`, shows the span the page gives it, read-only (`3/12`, `6/12`, `12/12`).

Nothing an author writes is added: there is no `span` key, and `@objectstack/spec` declares none. `@object-ui/components` adds `pageBlockSpan`, `isPageRailRegion`, `PAGE_BLOCK_SPANS` and the `PageBlockSpan` type to its entry, so the designer reads the renderer's table instead of a copy. The designer's new string lives in its own string tables (en and zh).
26 changes: 26 additions & 0 deletions content/docs/components/layout/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ heading. A page with no `pageType` is a `record` page, which draws neither: its
The [layout guide's Content Width](/docs/guide/layout#content-width) section covers the width
each type sets.

When a titled `page:header` takes over the heading, it also draws the page's `description`,
under its title and subtitle, so the description never sits above the title.

## Region Block Grid

A page's `regions` lay their blocks out on a 12-column grid. A block's width comes from its
kind, not from a key on the block: a metric tile (`element:number`, `object-metric`,
`statistic`) spans 3 columns, a chart (`chart`, `object-chart`) spans 6, and every other block
spans all 12. The spans fold by screen width:

| Screen width | Metric tile | Chart | Other blocks |
|---|---|---|---|
| 1280px and wider | 3 | 6 | 12 |
| 768px to 1279px | 6 | 12 | 12 |
| under 768px | 12 | 12 | 12 |

So four metric tiles share one row on a wide screen and take two rows on a 1024px one. A
`sidebar` or `aside` region beside `main` is a narrow column, so its blocks always span 12.
On the `dashboard` template each region is a cell of the page's region grid and lays its
own blocks out the same way. A page's `children` (no `regions`) stack one block per row.

A `page:card` or `page:section` lays its children on the same grid. The container spans 12
in its region, and its children take their own kind's span, so a card holding four metric
tiles shows them in one row on a wide screen. Studio's page designer shows each block's
span on its card, and on the child cards of a `page:card` or `page:section`.

## Schema

```ts
Expand Down
3 changes: 3 additions & 0 deletions content/docs/layout/page-header.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ The renderer picks one of two layouts when it renders:

- **Bare heading** — no record in scope, or `recordChrome: false`. The title and subtitle
stack on the left, the action row sits on the right.

When the header's title is the page's heading, the page's own `description` is drawn as a
third line, under the title and subtitle.
- **Record chip** — a record page (a record context with its object schema) with the record
chrome on. The title renders as the record chip: the heading, the object's label, the
copy-id button and the follow star, with the record's picture beside the heading when the
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'Inherited from the default page — add a block to override this slot.',
'engine.pageBlockCanvas.regionEmpty': 'Empty region — drop a block here or use the add button below.',
'engine.pageBlockCanvas.select': 'Select {label}',
'engine.pageBlockCanvas.span': 'Spans {span} of 12 columns on a wide screen',
'engine.pageBlockCanvas.groupEmpty': 'Empty — add a block.',
'engine.pageBlockCanvas.searchTypes': 'Search block type…',
'engine.pageBlockCanvas.noMatch': 'No matching types.',
Expand Down Expand Up @@ -5224,6 +5225,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.pageBlockCanvas.slotInherited': '继承自默认页面 —— 添加一个区块即可覆盖此插槽。',
'engine.pageBlockCanvas.regionEmpty': '空区域 —— 将区块拖放到这里,或使用下方的添加按钮。',
'engine.pageBlockCanvas.select': '选择 {label}',
'engine.pageBlockCanvas.span': '宽屏下占 12 列中的 {span} 列',
'engine.pageBlockCanvas.groupEmpty': '空 —— 添加一个区块。',
'engine.pageBlockCanvas.searchTypes': '搜索区块类型…',
'engine.pageBlockCanvas.noMatch': '没有匹配的类型。',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The page designer's block card shows the span the block takes in its
* region's 12-column grid (objectui#12624), read-only, from the page
* renderer's own table in `@object-ui/components` — so the card and the page
* cannot disagree.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { PAGE_BLOCK_SPANS } from '@object-ui/components';
// Module scope, not a hook: the registrations the span table names
// (AGENTS.md §测试纪律).
import '@object-ui/plugin-dashboard';
import '@object-ui/plugin-charts';
import { PageBlockCanvas } from './PageBlockCanvas';

afterEach(cleanup);

function spanBadges(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll('[data-block-span]')).map(
(el) => el.getAttribute('data-block-span') ?? '',
);
}

describe('PageBlockCanvas — the block card shows its derived span (objectui#12624)', () => {
it('a metric tile reads 3/12, a chart 6/12 and a block the table does not name 12/12', () => {
const { container } = render(
<PageBlockCanvas
draft={{
name: 'home',
type: 'home',
regions: [
{
name: 'main',
components: [
{ type: 'object-metric', id: 'revenue' },
{ type: 'object-chart', id: 'trend' },
{ type: 'element:text', id: 'note' },
],
},
],
}}
/>,
);
expect(spanBadges(container)).toEqual(['3', '6', '12']);
const badge = container.querySelector('[data-block-span="3"]')!;
expect(badge.textContent).toBe('3/12');
// Read-only: the badge is not a control.
expect(badge.closest('input, select, [role="spinbutton"]')).toBeNull();
});

it('a block in a rail region reads 12/12, as the page draws it', () => {
const { container } = render(
<PageBlockCanvas
draft={{
name: 'home',
type: 'home',
template: 'three-column',
regions: [
{ name: 'sidebar', components: [{ type: 'object-metric', id: 'a' }] },
{ name: 'main', components: [{ type: 'object-metric', id: 'b' }] },
],
}}
/>,
);
expect(spanBadges(container)).toEqual(['12', '3']);
});

it('a `children` page stacks its blocks, so its cards show no span', () => {
const { container } = render(
<PageBlockCanvas draft={{ name: 'home', type: 'home', children: [{ type: 'object-metric', id: 'a' }] }} />,
);
expect(container.querySelector('[data-block-span]')).toBeNull();
});

it('the children of a page:card show the span the card lays them out at', () => {
const { container } = render(
<PageBlockCanvas
draft={{
name: 'home',
type: 'home',
regions: [
{
name: 'main',
components: [
{
type: 'page:card',
id: 'kpis',
properties: {
title: 'Key Performance Indicators',
children: [
{ type: 'object-metric', id: 'a' },
{ type: 'object-metric', id: 'b' },
{ type: 'element:text', id: 'c' },
],
},
},
],
},
],
}}
/>,
);
// The card itself (12), then its three children.
expect(spanBadges(container)).toEqual(['12', '3', '3', '12']);
});

it('every type the span table names is a registered block type', () => {
const unregistered = [...PAGE_BLOCK_SPANS.keys()].filter(
(type) => !ComponentRegistry.has(type) && !ComponentRegistry.hasLazy(type),
);
expect(unregistered).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
Badge,
Button,
cn,
isPageRailRegion,
pageBlockSpan,
type PageBlockSpan,
Popover,
PopoverContent,
PopoverTrigger,
Expand Down Expand Up @@ -491,6 +494,7 @@ export function PageBlockCanvas({
baseIdOf={(compIdx) => selectionId(shape, regionIdx, compIdx, region.name)}
onSelectId={(sid, lbl) => onSelectionChange?.({ kind: 'block', id: sid, label: lbl })}
onAddNested={addNestedBlock}
template={(draft as { template?: unknown }).template}
locale={locale}
/>
))}
Expand Down Expand Up @@ -521,6 +525,7 @@ function RegionSection({
onRenameLabel,
onSelectId,
onAddNested,
template,
locale,
}: {
region: Region;
Expand All @@ -540,9 +545,16 @@ function RegionSection({
baseIdOf: (compIdx: number) => string;
onSelectId: (id: string, label: string) => void;
onAddNested: (baseId: string, pathSuffix: string, type: BlockTypeId) => void;
/** The page's `template`, which decides whether this region is a rail. */
template?: unknown;
locale?: string;
}) {
const comps = Array.isArray(region.components) ? region.components : [];
// The span each block takes in the page's region grid (objectui#12624), read
// from the page renderer's own table so the card cannot disagree with the
// page. Only a `regions` page lays its blocks on that grid: a `children` page
// stacks them, so its cards show no span.
const rail = isPageRailRegion(region.name, template);
const [active, setActive] = React.useState(false);

const handleDragOver = (e: React.DragEvent) => {
Expand Down Expand Up @@ -606,6 +618,7 @@ function RegionSection({
onClick={() => onSelectBlock(compIdx, blk)}
onMoveBlock={onMoveBlock}
onRenameLabel={(v) => onRenameLabel(regionIdx, compIdx, v)}
span={shape === 'regions' ? pageBlockSpan(blk.type, rail) : undefined}
locale={locale}
/>
<NestedChildren
Expand All @@ -615,6 +628,7 @@ function RegionSection({
readOnly={readOnly}
onSelectId={onSelectId}
onAddNested={onAddNested}
rail={rail}
locale={locale}
/>
</React.Fragment>
Expand All @@ -633,6 +647,20 @@ function RegionSection({

/* ─────────────── Block row ─────────────── */

/** A block's derived span in the page block grid, read-only (objectui#12624). */
function SpanBadge({ span, locale }: { span: PageBlockSpan; locale?: string }) {
return (
<Badge
variant="outline"
className="text-[10px] shrink-0 font-mono bg-background/90"
data-block-span={span}
title={tFormat('engine.pageBlockCanvas.span', locale, { span })}
>
{span}/12
</Badge>
);
}

function BlockRow({
block,
regionIdx,
Expand All @@ -642,6 +670,7 @@ function BlockRow({
onClick,
onMoveBlock,
onRenameLabel,
span,
locale,
}: {
block: Block;
Expand All @@ -657,9 +686,12 @@ function BlockRow({
| { region: number; appendEnd: true },
) => void;
onRenameLabel: (nextLabel: string) => void;
/** The block's derived span in the region grid; read-only, nothing edits it. */
span?: PageBlockSpan;
locale?: string;
}) {
const typeStr = String(block.type ?? '');
const spanBadge = span !== undefined && <SpanBadge span={span} locale={locale} />;
// DISPLAY meta, not the palette catalogue. `BLOCK_TYPE_META` answers "what may
// an author drag in"; a node already in the page may be a spelling the palette
// does not offer yet the app renders fine — the `record:discussion` /
Expand Down Expand Up @@ -792,9 +824,12 @@ function BlockRow({
<code className="text-[10px] text-muted-foreground/70 font-mono truncate">#{block.id}</code>
)}
</div>
<Badge variant="outline" className={cn('text-[10px] shrink-0 font-mono', tone.badge)}>
{typeStr}
</Badge>
<div className="flex items-center gap-1 shrink-0">
{spanBadge}
<Badge variant="outline" className={cn('text-[10px] shrink-0 font-mono', tone.badge)}>
{typeStr}
</Badge>
</div>
</button>
) : (
<>
Expand All @@ -819,6 +854,7 @@ function BlockRow({
)}
>
{draggable && <GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" aria-hidden="true" />}
{spanBadge}
<Badge variant="outline" className={cn('text-[10px] font-mono shadow-sm bg-background/90 backdrop-blur', tone.badge)}>
{typeStr}
</Badge>
Expand All @@ -842,6 +878,7 @@ function NestedChildren({
readOnly,
onSelectId,
onAddNested,
rail = false,
locale,
}: {
block: Block;
Expand All @@ -850,10 +887,15 @@ function NestedChildren({
readOnly: boolean;
onSelectId: (id: string, label: string) => void;
onAddNested: (baseId: string, pathSuffix: string, type: BlockTypeId) => void;
/** The block sits in a rail region, where every block spans 12. */
rail?: boolean;
locale?: string;
}) {
const groups = childGroups(block, locale);
if (groups.length === 0) return null;
// `page:card` and `page:section` lay their children on the page block grid
// at runtime (objectui#12624), so their child cards show the span too.
const showsSpan = block?.type === 'page:card' || block?.type === 'page:section';
// A grid lays its children in N columns at runtime — mirror that here so the
// design canvas matches the preview (other containers stack in one column).
const gridCols =
Expand Down Expand Up @@ -894,10 +936,11 @@ function NestedChildren({
/>
<div
className={cn(
'pointer-events-none absolute right-1.5 top-1.5 z-20 transition-opacity',
'pointer-events-none absolute right-1.5 top-1.5 z-20 flex items-center gap-1 transition-opacity',
childSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
>
{showsSpan && <SpanBadge span={pageBlockSpan(child?.type, rail)} locale={locale} />}
<Badge variant="outline" className={cn('text-[10px] font-mono shadow-sm bg-background/90 backdrop-blur', resolveBlockTone(typeStr).badge)}>
{typeStr}
</Badge>
Expand Down
Loading
Loading