diff --git a/.changeset/12624-page-block-grid.md b/.changeset/12624-page-block-grid.md new file mode 100644 index 0000000000..7b694fcffe --- /dev/null +++ b/.changeset/12624-page-block-grid.md @@ -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). diff --git a/content/docs/components/layout/page.mdx b/content/docs/components/layout/page.mdx index 077dfcdfb6..35b94ce246 100644 --- a/content/docs/components/layout/page.mdx +++ b/content/docs/components/layout/page.mdx @@ -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 diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index 76101d4ed1..095f39dfcf 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -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 diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index b0be1947df..5c72ed879f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1791,6 +1791,7 @@ const ENGINE_STRINGS_EN: Record = { '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.', @@ -5224,6 +5225,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.pageBlockCanvas.slotInherited': '继承自默认页面 —— 添加一个区块即可覆盖此插槽。', 'engine.pageBlockCanvas.regionEmpty': '空区域 —— 将区块拖放到这里,或使用下方的添加按钮。', 'engine.pageBlockCanvas.select': '选择 {label}', + 'engine.pageBlockCanvas.span': '宽屏下占 12 列中的 {span} 列', 'engine.pageBlockCanvas.groupEmpty': '空 —— 添加一个区块。', 'engine.pageBlockCanvas.searchTypes': '搜索区块类型…', 'engine.pageBlockCanvas.noMatch': '没有匹配的类型。', diff --git a/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.blockSpan-12624.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.blockSpan-12624.test.tsx new file mode 100644 index 0000000000..49f04ccfed --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.blockSpan-12624.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(spanBadges(container)).toEqual(['12', '3']); + }); + + it('a `children` page stacks its blocks, so its cards show no span', () => { + const { container } = render( + , + ); + 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( + , + ); + // 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([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx index b4c81421ca..b39f9d41b3 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx @@ -20,6 +20,9 @@ import { Badge, Button, cn, + isPageRailRegion, + pageBlockSpan, + type PageBlockSpan, Popover, PopoverContent, PopoverTrigger, @@ -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} /> ))} @@ -521,6 +525,7 @@ function RegionSection({ onRenameLabel, onSelectId, onAddNested, + template, locale, }: { region: Region; @@ -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) => { @@ -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} /> @@ -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 ( + + {span}/12 + + ); +} + function BlockRow({ block, regionIdx, @@ -642,6 +670,7 @@ function BlockRow({ onClick, onMoveBlock, onRenameLabel, + span, locale, }: { block: Block; @@ -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 && ; // 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` / @@ -792,9 +824,12 @@ function BlockRow({ #{block.id} )} - - {typeStr} - +
+ {spanBadge} + + {typeStr} + +
) : ( <> @@ -819,6 +854,7 @@ function BlockRow({ )} > {draggable &&