feat(page): add composable page primitives

This commit is contained in:
Maofeng
2026-09-28 23:13:49 +08:00
parent 0ec968c344
commit d23deca0e7
18 changed files with 1979 additions and 0 deletions
@@ -0,0 +1,71 @@
---
title: Title portals
description: Mount page titles into an application shell while retaining an SSR fallback.
order: 20
toc:
- id: info-resolver
title: Page information resolver
- id: managed-target
title: Managed title target
- id: fallback
title: Fallback
- id: nested-managers
title: Nested managers
---
## Page information resolver {#info-resolver}
`PagesManager.getPageInfo` connects route or layout metadata to page components. It receives the optional `Partial<PageInfo>` from `PageContainer` and returns a normalized `PageInfo`, or `undefined` when no managed information is available:
```tsx
<PagesManager
getPageInfo={(info) => ({
breadcrumb: info?.breadcrumb ?? routeMetadata.breadcrumb,
description: info?.description ?? routeMetadata.description,
subtitle: info?.subtitle ?? routeMetadata.subtitle,
title: info?.title ?? routeMetadata.title,
})}
>
<Outlet />
</PagesManager>
```
If the resolver returns `undefined`, `PageContainer` still merges its local partial information with inherited page information.
## Managed title target {#managed-target}
A layout can give `PagesManager` a DOM container while a page places its title in the external Header through `PageTitlePortal`:
```tsx
function ApplicationLayout() {
const [titleContainer, setTitleContainer] =
React.useState<HTMLElement | null>(null)
return (
<PagesManager titleContainer={titleContainer}>
<header>
<div ref={setTitleContainer} />
</header>
<Outlet />
</PagesManager>
)
}
function ProductsPage() {
return (
<PageTitlePortal>
<PageTitle>Products</PageTitle>
</PageTitlePortal>
)
}
```
`titleContainer` accepts an `Element` or `DocumentFragment`. A `PageTitlePortal` may override the manager with its own `container` prop.
## Fallback {#fallback}
Without an available target, the Portal renders `children` in place by default, so server rendering and the first client render retain the page title. Supply `fallback` to replace the inline content, including `null` to hide it.
## Nested managers {#nested-managers}
A nested `PagesManager` inherits its parent's `getPageInfo` and title target when they are omitted. Pass `null` as `titleContainer` explicitly to stop title-target inheritance and keep titles in that subtree inline.
@@ -0,0 +1,74 @@
---
title: Product page example
description: Compose a responsive product management page with route links.
order: 30
toc:
- id: page-tabs
title: Page tabs
- id: complete-example
title: Complete example
---
## Page tabs {#page-tabs}
`PageTabs` follows the standard Root, List, Trigger, Indicator, and Content composition:
```tsx
<PageTabs defaultValue="overview" placement="top-start" variant="default">
<PageTabsList aria-label="Product pages">
<PageTabsExtra>
<Button variant="ghost">Back</Button>
</PageTabsExtra>
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
<PageTabsTrigger value="inventory">Inventory</PageTabsTrigger>
<PageTabsTrigger value="settings">Settings</PageTabsTrigger>
<PageTabsIndicator />
<PageTabsExtra align="end">
<Button variant="ghost">Help</Button>
</PageTabsExtra>
</PageTabsList>
<PageTabsContent value="overview">Product overview</PageTabsContent>
<PageTabsContent value="inventory">Inventory management</PageTabsContent>
<PageTabsContent value="settings">Product settings</PageTabsContent>
</PageTabs>
```
`placement` combines `top | right | bottom | left` with `start | center | end`, for example `top-start`, `top-center`, or `left-end`. Top and bottom are horizontal; left and right are vertical. The suffix aligns the tabs group on its main axis.
`variant` accepts `line`, `card`, or `default`. `line` draws a foreground edge beneath the active Trigger, `card` joins bordered tabs to the content edge, and `default` renders a muted rounded track with a raised active surface. `PageTabsIndicator` follows the active Trigger automatically.
`PageTabsExtra` uses `align="start | end"` to occupy either edge of the List and defaults to `start`. Multiple extras may share an edge while retaining source order.
## Complete example {#complete-example}
```tsx
<PageContainer
info={{
breadcrumb: [
{ label: "Products", href: "/products" },
{ label: "Wireless headphones" },
],
description: "Update details, inventory, and sales channels.",
title: "Wireless headphones",
}}
>
<PageHeader>
<PageBreadcrumb aria-label="Current location" />
<PageHeading>
<PageTitle />
<PageDescription />
<PageHeadingExtra align="end">
<Button variant="outline">Preview</Button>
<Button>Save</Button>
</PageHeadingExtra>
</PageHeading>
<PageHeaderExtra>
<ProductMetadata />
</PageHeaderExtra>
</PageHeader>
<ProductRouteTabs />
<PageSection aria-label="Product details">
<ProductForm />
</PageSection>
</PageContainer>
```
@@ -0,0 +1,94 @@
---
title: Page composition
description: Compose headings, page actions, breadcrumbs, and content sections.
order: 10
toc:
- id: stylesheet
title: Stylesheet
- id: basic-page
title: Basic page
- id: page-info
title: Page information
- id: breadcrumb
title: Breadcrumbs
---
## Stylesheet {#stylesheet}
Add the package source to the application's Tailwind stylesheet:
```css
@import "@workspace/page/globals.css";
```
## Basic page {#basic-page}
```tsx
import {
PageBackButton,
PageBreadcrumb,
PageContainer,
PageDescription,
PageHeader,
PageHeaderExtra,
PageHeading,
PageHeadingExtra,
PageSection,
PageSubtitle,
PageTitle,
} from "@workspace/page"
import { Button } from "@workspace/ui/components/button"
export function ProductsPage() {
return (
<PageContainer
info={{
breadcrumb: [
{ label: "Home", href: "/" },
{ label: "Catalog", href: "/products" },
{ label: "Products" },
],
description: "Manage products, pricing, and availability.",
subtitle: "Catalog",
title: "Products",
}}
>
<PageHeader>
<PageBreadcrumb />
<PageHeading>
<PageHeadingExtra align="start">
<PageBackButton href="/products">Products</PageBackButton>
</PageHeadingExtra>
<PageTitle />
<PageSubtitle />
<PageDescription />
<PageHeadingExtra align="end">
<Button>New product</Button>
</PageHeadingExtra>
</PageHeading>
<PageHeaderExtra>
<ProductStatus />
</PageHeaderExtra>
</PageHeader>
<PageSection aria-label="Product list">
<ProductsTable />
</PageSection>
</PageContainer>
)
}
```
## Page information {#page-info}
`PageContainer.info` accepts `Partial<PageInfo>` and exposes the resolved value through `usePageInfo()`. When a surrounding `PagesManager` supplies `getPageInfo`, the function receives this partial value and may complete it with route or layout metadata. `PageTitle`, `PageSubtitle`, `PageDescription`, and `PageBreadcrumb` use matching information when their own content or items are omitted. Explicit component content takes precedence. A component returns `null` when neither source supplies renderable content, so optional metadata does not leave empty elements in the page.
`PageHeadingExtra` accepts `align="start | end"`. Multiple extras on the same side retain source order, while `PageHeaderExtra` provides an unrestricted region below the heading. Every container retains `className` and its native HTML props.
`PageBackButton` uses browser history when no destination is supplied. Pass `href` for a native link, or `render` for a router-specific Link. Preventing the custom `onClick` event also prevents the default history navigation.
## Breadcrumbs {#breadcrumb}
Pass a root-to-current-page `items` path to `PageBreadcrumb`. The last item is rendered as the current page; items with `href` use native anchors, while `render` accepts a router-specific Link. When the path exceeds `maxItems` (default `4`), middle items move into an accessible overflow menu.
Omit `items` to use the original compositional API with Breadcrumb Item, Link, and Separator components from `@workspace/ui/components/breadcrumb`.
+37
View File
@@ -0,0 +1,37 @@
---
title: Overview
description: Build consistent page structure with router-neutral atomic components.
order: 1
toc:
- id: role
title: Package role
- id: components
title: Components
- id: boundaries
title: Design boundaries
---
## Package role {#role}
`@workspace/page` provides structural components for application content pages: headings, descriptions, actions, breadcrumbs, route tabs, and sections. It owns semantics, responsive layout, and visual conventions without creating routes or managing application data.
## Components {#components}
- `PageContainer` is the vertical page container.
- `PageInfo` and `usePageInfo` share optional page metadata with structural components.
- `PageHeader` and `PageHeading` organize primary heading content.
- `PageTitle`, `PageSubtitle`, and `PageDescription` provide semantic heading information.
- `PageHeadingExtra` places arbitrary content at the start or end of the heading.
- `PageHeaderExtra` provides an unrestricted region below the heading.
- `PageBackButton` provides history, native-link, and router-link navigation.
- `PageBreadcrumb` renders item paths with an overflow menu or reuses the compositional Breadcrumb API from `@workspace/ui`.
- `PageTabs` manages controlled or uncontrolled values, orientation, and keyboard interaction.
- `PageTabsList`, `PageTabsTrigger`, and `PageTabsIndicator` compose a four-sided tab list with multiple active styles.
- `PageTabsExtra` places one or more page actions at the start or end edge of the list.
- `PageTabsContent` renders the panel matching a Trigger value.
- `PageSection` marks an independent semantic content region.
- `PagesManager` and `PageTitlePortal` optionally mount titles into an external layout.
## Design boundaries {#boundaries}
`PageInfo` supplies presentation metadata rather than router state. Explicit component content overrides matching metadata, and missing values render nothing. Page Tabs use Base UI to manage values, panel visibility, keyboard navigation, and indicator positioning without creating application routes. Synchronize `value` and `onValueChange` at the application layer when tabs map to routes. `PagesManager` does not own a page stack; it only provides a title container to its subtree.