feat(page): add composable page primitives
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: 标题 Portal
|
||||
description: 将页面标题安全地挂载到应用外壳,同时保留 SSR fallback。
|
||||
order: 20
|
||||
toc:
|
||||
- id: info-resolver
|
||||
title: 页面信息解析器
|
||||
- id: managed-target
|
||||
title: 管理标题目标
|
||||
- id: fallback
|
||||
title: Fallback
|
||||
- id: nested-managers
|
||||
title: 嵌套 Manager
|
||||
---
|
||||
|
||||
## 页面信息解析器 {#info-resolver}
|
||||
|
||||
`PagesManager.getPageInfo` 用于把路由或布局元数据接入页面组件。它接收 `PageContainer` 可选的 `Partial<PageInfo>`,返回补全后的 `PageInfo`;没有可用的托管信息时也可以返回 `undefined`:
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
解析器返回 `undefined` 时,`PageContainer` 仍会把自身局部信息与继承的页面信息合并。
|
||||
|
||||
## 管理标题目标 {#managed-target}
|
||||
|
||||
布局可以把 DOM 容器交给 `PagesManager`,页面再通过 `PageTitlePortal` 把标题放进外部 Header:
|
||||
|
||||
```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>商品</PageTitle>
|
||||
</PageTitlePortal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`titleContainer` 接受 `Element` 或 `DocumentFragment`。`PageTitlePortal` 也可以通过自己的 `container` prop 覆盖 Manager。
|
||||
|
||||
## Fallback {#fallback}
|
||||
|
||||
没有可用目标时,Portal 默认原位渲染 `children`,因此服务端渲染和第一次客户端渲染不会丢失页面标题。传入 `fallback` 可以替换原位内容,包括传入 `null` 隐藏它。
|
||||
|
||||
## 嵌套 Manager {#nested-managers}
|
||||
|
||||
嵌套 `PagesManager` 在未传 `getPageInfo` 和 `titleContainer` 时会继承父级解析器与标题目标。显式传入 `null` 作为 `titleContainer` 可以阻止标题目标继承,使该子树中的标题继续原位渲染。
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: 商品页面示例
|
||||
description: 使用路由链接组合响应式商品管理页面。
|
||||
order: 30
|
||||
toc:
|
||||
- id: page-tabs
|
||||
title: 页面 Tabs
|
||||
- id: complete-example
|
||||
title: 完整示例
|
||||
---
|
||||
|
||||
## 页面 Tabs {#page-tabs}
|
||||
|
||||
`PageTabs` 采用标准的 Root、List、Trigger、Indicator 和 Content 组合:
|
||||
|
||||
```tsx
|
||||
<PageTabs defaultValue="overview" placement="top-start" variant="default">
|
||||
<PageTabsList aria-label="商品页面">
|
||||
<PageTabsExtra>
|
||||
<Button variant="ghost">返回</Button>
|
||||
</PageTabsExtra>
|
||||
<PageTabsTrigger value="overview">概览</PageTabsTrigger>
|
||||
<PageTabsTrigger value="inventory">库存</PageTabsTrigger>
|
||||
<PageTabsTrigger value="settings">设置</PageTabsTrigger>
|
||||
<PageTabsIndicator />
|
||||
<PageTabsExtra align="end">
|
||||
<Button variant="ghost">帮助</Button>
|
||||
</PageTabsExtra>
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="overview">商品概览</PageTabsContent>
|
||||
<PageTabsContent value="inventory">库存管理</PageTabsContent>
|
||||
<PageTabsContent value="settings">商品设置</PageTabsContent>
|
||||
</PageTabs>
|
||||
```
|
||||
|
||||
`placement` 由 `top | right | bottom | left` 和 `start | center | end` 组合,例如 `top-start`、`top-center`、`left-end`。顶部和底部横向排列,左侧和右侧纵向排列;后缀控制 Tabs 组在主轴上的对齐。
|
||||
|
||||
`variant` 接受 `line`、`card` 或 `default`。`line` 在当前 Trigger 的内容侧绘制前景色线条,`card` 将带边框的标签与内容边界衔接,`default` 则渲染带凸起选中面的圆角浅色轨道。`PageTabsIndicator` 会自动跟随当前 Trigger。
|
||||
|
||||
`PageTabsExtra` 通过 `align="start | end"` 放到 List 两端,默认是 `start`。同一端可以声明多个 Extra,它们会保持原始顺序。
|
||||
|
||||
## 完整示例 {#complete-example}
|
||||
|
||||
```tsx
|
||||
<PageContainer
|
||||
info={{
|
||||
breadcrumb: [{ label: "商品", href: "/products" }, { label: "蓝牙耳机" }],
|
||||
description: "更新详情、库存和销售渠道。",
|
||||
title: "蓝牙耳机",
|
||||
}}
|
||||
>
|
||||
<PageHeader>
|
||||
<PageBreadcrumb aria-label="当前位置" />
|
||||
<PageHeading>
|
||||
<PageTitle />
|
||||
<PageDescription />
|
||||
<PageHeadingExtra align="end">
|
||||
<Button variant="outline">预览</Button>
|
||||
<Button>保存</Button>
|
||||
</PageHeadingExtra>
|
||||
</PageHeading>
|
||||
<PageHeaderExtra>
|
||||
<ProductMetadata />
|
||||
</PageHeaderExtra>
|
||||
</PageHeader>
|
||||
<ProductRouteTabs />
|
||||
<PageSection aria-label="商品详情">
|
||||
<ProductForm />
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: 页面组合
|
||||
description: 组合标题、页面操作、面包屑和内容 Section。
|
||||
order: 10
|
||||
toc:
|
||||
- id: stylesheet
|
||||
title: 样式入口
|
||||
- id: basic-page
|
||||
title: 基础页面
|
||||
- id: page-info
|
||||
title: 页面信息
|
||||
- id: breadcrumb
|
||||
title: 面包屑
|
||||
---
|
||||
|
||||
## 样式入口 {#stylesheet}
|
||||
|
||||
在应用 Tailwind 样式入口加入:
|
||||
|
||||
```css
|
||||
@import "@workspace/page/globals.css";
|
||||
```
|
||||
|
||||
## 基础页面 {#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: "首页", href: "/" },
|
||||
{ label: "商品目录", href: "/products" },
|
||||
{ label: "商品" },
|
||||
],
|
||||
description: "管理商品、价格和上架状态。",
|
||||
subtitle: "商品目录",
|
||||
title: "商品",
|
||||
}}
|
||||
>
|
||||
<PageHeader>
|
||||
<PageBreadcrumb />
|
||||
<PageHeading>
|
||||
<PageHeadingExtra align="start">
|
||||
<PageBackButton href="/products">商品列表</PageBackButton>
|
||||
</PageHeadingExtra>
|
||||
<PageTitle />
|
||||
<PageSubtitle />
|
||||
<PageDescription />
|
||||
<PageHeadingExtra align="end">
|
||||
<Button>新建商品</Button>
|
||||
</PageHeadingExtra>
|
||||
</PageHeading>
|
||||
<PageHeaderExtra>
|
||||
<ProductStatus />
|
||||
</PageHeaderExtra>
|
||||
</PageHeader>
|
||||
|
||||
<PageSection aria-label="商品列表">
|
||||
<ProductsTable />
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 页面信息 {#page-info}
|
||||
|
||||
`PageContainer.info` 接受 `Partial<PageInfo>`,并通过 `usePageInfo()` 暴露解析后的值。外层 `PagesManager` 提供 `getPageInfo` 后,该函数会接收局部信息,并可使用路由或布局元数据将其补全。`PageTitle`、`PageSubtitle`、`PageDescription` 和 `PageBreadcrumb` 在自身未提供内容或 items 时读取对应的页面信息;组件显式提供的内容优先。两个来源都没有可渲染内容时,组件返回 `null`,不会在页面中留下空元素。
|
||||
|
||||
`PageHeadingExtra` 接受 `align="start | end"`。同一侧的多个 Extra 会保持声明顺序,`PageHeaderExtra` 则提供标题下方不限定内容类型的区域。所有容器都保留 `className` 和原生 HTML props。
|
||||
|
||||
`PageBackButton` 未指定目标地址时使用浏览器历史记录返回。传入 `href` 可渲染原生链接,传入 `render` 可接入特定路由库的 Link;自定义 `onClick` 调用 `preventDefault()` 后也会阻止默认的历史返回。
|
||||
|
||||
## 面包屑 {#breadcrumb}
|
||||
|
||||
按照从根节点到当前页面的顺序向 `PageBreadcrumb` 传入 `items`。最后一项会渲染为当前页面;带 `href` 的项目使用原生链接,`render` 则可以接入特定路由库的 Link。当路径超过 `maxItems`(默认 `4`)时,中间项目会收入支持键盘操作的溢出菜单。
|
||||
|
||||
省略 `items` 后仍可使用原有组合 API,Breadcrumb Item、Link 和 Separator 继续从 `@workspace/ui/components/breadcrumb` 导入。
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: 概览
|
||||
description: 使用路由无关的原子组件建立一致的页面结构。
|
||||
order: 1
|
||||
toc:
|
||||
- id: role
|
||||
title: 包的职责
|
||||
- id: components
|
||||
title: 组件组成
|
||||
- id: boundaries
|
||||
title: 设计边界
|
||||
---
|
||||
|
||||
## 包的职责 {#role}
|
||||
|
||||
`@workspace/page` 提供应用内容页的结构组件,包括标题、说明、操作区、面包屑、路由标签和内容 Section。它只负责语义、响应式布局和视觉约定,不创建路由,也不管理业务数据。
|
||||
|
||||
## 组件组成 {#components}
|
||||
|
||||
- `PageContainer`:页面内容的纵向根容器。
|
||||
- `PageInfo`、`usePageInfo`:向页面结构组件共享可选的页面元信息。
|
||||
- `PageHeader`、`PageHeading`:组织主要标题内容。
|
||||
- `PageTitle`、`PageSubtitle`、`PageDescription`:语义化标题信息。
|
||||
- `PageHeadingExtra`:在标题的起始侧或末尾侧放置任意内容。
|
||||
- `PageHeaderExtra`:提供标题下方不限定内容类型的区域。
|
||||
- `PageBackButton`:支持历史返回、原生链接及路由 Link。
|
||||
- `PageBreadcrumb`:通过数据生成带溢出菜单的路径,也可以复用 `@workspace/ui` 的组合式 Breadcrumb API。
|
||||
- `PageTabs`:管理受控或非受控的当前值、方向和键盘交互。
|
||||
- `PageTabsList`、`PageTabsTrigger`、`PageTabsIndicator`:组成支持四侧放置和多种活动样式的标签列表。
|
||||
- `PageTabsExtra`:把一个或多个页面级操作放到标签列表的起始端或末端。
|
||||
- `PageTabsContent`:渲染与 Trigger 值匹配的内容面板。
|
||||
- `PageSection`:页面主体的独立语义区段。
|
||||
- `PagesManager`、`PageTitlePortal`:可选地把标题挂载到外部布局。
|
||||
|
||||
## 设计边界 {#boundaries}
|
||||
|
||||
`PageInfo` 只提供展示元信息,不管理路由状态。组件显式内容会覆盖对应元信息,缺少值时不渲染空节点。Page Tabs 基于 Base UI 管理值、Panel 显隐、键盘导航和 Indicator 定位,但不创建应用路由。需要与路由同步时,通过 `value` 和 `onValueChange` 在应用层完成。`PagesManager` 不保存页面栈,只向子树提供标题容器。
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@workspace/page",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./globals.css": "./src/styles/globals.css"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@workspace/ui/components/breadcrumb"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
import { uiMessages, useMessage } from "@workspace/ui/components/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { isRenderablePageInfoValue, usePageInfo } from "./page-info"
|
||||
|
||||
export interface PageBreadcrumbItem {
|
||||
/** Native destination. Use `render` instead for a router-specific Link. */
|
||||
href?: string
|
||||
key?: React.Key
|
||||
label: React.ReactNode
|
||||
/** Router-specific link element, such as `<Link to="/products" />`. */
|
||||
render?: React.ReactElement
|
||||
}
|
||||
|
||||
export interface PageBreadcrumbProps extends Omit<
|
||||
React.ComponentProps<typeof Breadcrumb>,
|
||||
"children"
|
||||
> {
|
||||
children?: React.ReactNode
|
||||
/** Breadcrumb path in root-to-current-page order. */
|
||||
items?: readonly PageBreadcrumbItem[]
|
||||
/** Maximum visible entries, including the collapsed-items trigger. */
|
||||
maxItems?: number
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageBreadcrumb({
|
||||
children,
|
||||
className,
|
||||
items,
|
||||
maxItems = 4,
|
||||
separator,
|
||||
...props
|
||||
}: PageBreadcrumbProps) {
|
||||
const pageInfo = usePageInfo()
|
||||
const resolvedItems =
|
||||
items === undefined && children === undefined ? pageInfo.breadcrumb : items
|
||||
const resolvedChildren = items === undefined ? children : undefined
|
||||
|
||||
if (
|
||||
resolvedItems !== undefined
|
||||
? resolvedItems.length === 0
|
||||
: !isRenderablePageInfoValue(resolvedChildren)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb
|
||||
data-slot="page-breadcrumb"
|
||||
className={cn("min-w-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{resolvedItems ? (
|
||||
<PageBreadcrumbItems
|
||||
items={resolvedItems}
|
||||
maxItems={maxItems}
|
||||
separator={separator}
|
||||
/>
|
||||
) : (
|
||||
resolvedChildren
|
||||
)}
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
||||
function PageBreadcrumbItems({
|
||||
items,
|
||||
maxItems,
|
||||
separator,
|
||||
}: {
|
||||
items: readonly PageBreadcrumbItem[]
|
||||
maxItems: number
|
||||
separator?: React.ReactNode
|
||||
}) {
|
||||
const displayItems = getDisplayItems(items, maxItems)
|
||||
|
||||
return (
|
||||
<BreadcrumbList>
|
||||
{displayItems.map((displayItem, index) => (
|
||||
<React.Fragment key={getDisplayItemKey(displayItem, index)}>
|
||||
<BreadcrumbItem>
|
||||
{isCollapsedItems(displayItem) ? (
|
||||
<PageBreadcrumbMenu items={displayItem} />
|
||||
) : (
|
||||
<PageBreadcrumbEntry
|
||||
item={displayItem}
|
||||
current={index === displayItems.length - 1}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index < displayItems.length - 1 && (
|
||||
<BreadcrumbSeparator>{separator}</BreadcrumbSeparator>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
)
|
||||
}
|
||||
|
||||
function PageBreadcrumbEntry({
|
||||
current,
|
||||
item,
|
||||
}: {
|
||||
current: boolean
|
||||
item: PageBreadcrumbItem
|
||||
}) {
|
||||
if (current) {
|
||||
return <BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
}
|
||||
|
||||
const render = getItemRender(item)
|
||||
if (render) {
|
||||
return <BreadcrumbLink render={render}>{item.label}</BreadcrumbLink>
|
||||
}
|
||||
|
||||
return <span>{item.label}</span>
|
||||
}
|
||||
|
||||
function PageBreadcrumbMenu({
|
||||
items,
|
||||
}: {
|
||||
items: readonly PageBreadcrumbItem[]
|
||||
}) {
|
||||
const menuLabel = useMessage(uiMessages.morePages)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button aria-label={menuLabel} size="icon-sm" variant="ghost" />
|
||||
}
|
||||
>
|
||||
<BreadcrumbEllipsis />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuGroup>
|
||||
{items.map((item, index) => {
|
||||
const render = getItemRender(item)
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={getItemKey(item, index)}
|
||||
className={cn(
|
||||
!render && "cursor-default data-disabled:opacity-100"
|
||||
)}
|
||||
disabled={!render}
|
||||
render={render}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function getDisplayItems(
|
||||
items: readonly PageBreadcrumbItem[],
|
||||
maxItems: number
|
||||
): Array<PageBreadcrumbItem | readonly PageBreadcrumbItem[]> {
|
||||
const visibleCount = Number.isFinite(maxItems)
|
||||
? Math.max(3, Math.floor(maxItems))
|
||||
: 4
|
||||
if (items.length <= visibleCount) return [...items]
|
||||
|
||||
const trailingCount = visibleCount - 2
|
||||
return [
|
||||
items[0]!,
|
||||
items.slice(1, -trailingCount),
|
||||
...items.slice(-trailingCount),
|
||||
]
|
||||
}
|
||||
|
||||
function getItemRender(item: PageBreadcrumbItem) {
|
||||
return item.render ?? (item.href ? <a href={item.href} /> : undefined)
|
||||
}
|
||||
|
||||
function getItemKey(item: PageBreadcrumbItem, index: number): React.Key {
|
||||
return item.key ?? item.href ?? index
|
||||
}
|
||||
|
||||
function getDisplayItemKey(
|
||||
item: PageBreadcrumbItem | readonly PageBreadcrumbItem[],
|
||||
index: number
|
||||
): React.Key {
|
||||
if (isCollapsedItems(item)) return `collapsed-${index}`
|
||||
return getItemKey(item, index)
|
||||
}
|
||||
|
||||
function isCollapsedItems(
|
||||
item: PageBreadcrumbItem | readonly PageBreadcrumbItem[]
|
||||
): item is readonly PageBreadcrumbItem[] {
|
||||
return Array.isArray(item)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export { type GetPageInfo, type PageInfo, usePageInfo } from "./page-info"
|
||||
export {
|
||||
PageBreadcrumb,
|
||||
type PageBreadcrumbItem,
|
||||
type PageBreadcrumbProps,
|
||||
} from "./breadcrumb"
|
||||
export {
|
||||
PagesManager,
|
||||
PageTitlePortal,
|
||||
type PagesManagerProps,
|
||||
type PageTitlePortalProps,
|
||||
} from "./manager"
|
||||
export {
|
||||
PageBackButton,
|
||||
type PageBackButtonProps,
|
||||
PageContainer,
|
||||
type PageContainerProps,
|
||||
PageDescription,
|
||||
PageHeader,
|
||||
PageHeaderExtra,
|
||||
PageHeading,
|
||||
PageHeadingExtra,
|
||||
type PageHeadingExtraAlign,
|
||||
type PageHeadingExtraProps,
|
||||
PageSection,
|
||||
PageSubtitle,
|
||||
PageTitle,
|
||||
} from "./page"
|
||||
export {
|
||||
PageTabs,
|
||||
type PageTabsAlignment,
|
||||
PageTabsContent,
|
||||
type PageTabsContentProps,
|
||||
PageTabsExtra,
|
||||
type PageTabsExtraAlign,
|
||||
type PageTabsExtraProps,
|
||||
PageTabsIndicator,
|
||||
type PageTabsIndicatorProps,
|
||||
PageTabsList,
|
||||
type PageTabsListProps,
|
||||
type PageTabsPlacement,
|
||||
type PageTabsProps,
|
||||
type PageTabsSide,
|
||||
PageTabsTrigger,
|
||||
type PageTabsTriggerProps,
|
||||
type PageTabsVariant,
|
||||
} from "./tabs"
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import {
|
||||
GetPageInfoContext,
|
||||
type GetPageInfo,
|
||||
useGetPageInfo,
|
||||
} from "./page-info"
|
||||
|
||||
type PortalContainer = Element | DocumentFragment
|
||||
|
||||
const PageTitleContainerContext = React.createContext<PortalContainer | null>(
|
||||
null
|
||||
)
|
||||
PageTitleContainerContext.displayName = "PageTitleContainerContext"
|
||||
|
||||
export interface PagesManagerProps {
|
||||
children?: React.ReactNode
|
||||
/** Resolves route or layout metadata from optional page-local overrides. */
|
||||
getPageInfo?: GetPageInfo
|
||||
/** Inherits the parent manager when omitted; pass null to disable it. */
|
||||
titleContainer?: PortalContainer | null
|
||||
}
|
||||
|
||||
/** Supplies an optional title destination without owning page or router state. */
|
||||
export function PagesManager({
|
||||
children,
|
||||
getPageInfo,
|
||||
titleContainer,
|
||||
}: PagesManagerProps) {
|
||||
const parentContainer = React.useContext(PageTitleContainerContext)
|
||||
const parentGetPageInfo = useGetPageInfo()
|
||||
const container =
|
||||
titleContainer === undefined ? parentContainer : titleContainer
|
||||
const resolvedGetPageInfo = getPageInfo ?? parentGetPageInfo
|
||||
|
||||
return (
|
||||
<GetPageInfoContext.Provider value={resolvedGetPageInfo}>
|
||||
<PageTitleContainerContext.Provider value={container}>
|
||||
{children}
|
||||
</PageTitleContainerContext.Provider>
|
||||
</GetPageInfoContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PageTitlePortalProps {
|
||||
children?: React.ReactNode
|
||||
container?: PortalContainer | null
|
||||
/** Defaults to children so SSR and the first client render retain the title. */
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageTitlePortal({
|
||||
children,
|
||||
container,
|
||||
fallback,
|
||||
}: PageTitlePortalProps) {
|
||||
const managedContainer = React.useContext(PageTitleContainerContext)
|
||||
const resolvedContainer =
|
||||
container === undefined ? managedContainer : container
|
||||
|
||||
if (!resolvedContainer) {
|
||||
return fallback === undefined ? children : fallback
|
||||
}
|
||||
return createPortal(children, resolvedContainer)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { PageBreadcrumbItem } from "./breadcrumb"
|
||||
|
||||
export interface PageInfo {
|
||||
breadcrumb: readonly PageBreadcrumbItem[] | undefined
|
||||
description: React.ReactNode
|
||||
subtitle: React.ReactNode
|
||||
title: React.ReactNode
|
||||
}
|
||||
|
||||
export type GetPageInfo = (info?: Partial<PageInfo>) => PageInfo | undefined
|
||||
|
||||
const emptyPageInfo: PageInfo = {
|
||||
breadcrumb: undefined,
|
||||
description: undefined,
|
||||
subtitle: undefined,
|
||||
title: undefined,
|
||||
}
|
||||
|
||||
export const PageInfoContext = React.createContext<PageInfo>(emptyPageInfo)
|
||||
PageInfoContext.displayName = "PageInfoContext"
|
||||
|
||||
export const GetPageInfoContext = React.createContext<GetPageInfo | undefined>(
|
||||
undefined
|
||||
)
|
||||
GetPageInfoContext.displayName = "GetPageInfoContext"
|
||||
|
||||
/** Returns the closest page metadata, or an empty object outside a page. */
|
||||
export function usePageInfo(): PageInfo {
|
||||
return React.useContext(PageInfoContext)
|
||||
}
|
||||
|
||||
export function useGetPageInfo(): GetPageInfo | undefined {
|
||||
return React.useContext(GetPageInfoContext)
|
||||
}
|
||||
|
||||
export function isRenderablePageInfoValue(value: React.ReactNode): boolean {
|
||||
if (value === null || value === undefined || typeof value === "boolean") {
|
||||
return false
|
||||
}
|
||||
if (typeof value === "string") return value.length > 0
|
||||
if (Array.isArray(value)) return value.some(isRenderablePageInfoValue)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import * as React from "react"
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import {
|
||||
PageBreadcrumb,
|
||||
PageBackButton,
|
||||
PageContainer,
|
||||
PageDescription,
|
||||
PageHeader,
|
||||
PageHeaderExtra,
|
||||
PageHeading,
|
||||
PageHeadingExtra,
|
||||
type PageInfo,
|
||||
PageSection,
|
||||
PageSubtitle,
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsExtra,
|
||||
PageTabsIndicator,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
PageTitle,
|
||||
PageTitlePortal,
|
||||
PagesManager,
|
||||
usePageInfo,
|
||||
} from "@workspace/page"
|
||||
import {
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from "@workspace/ui/components/breadcrumb"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { vi } from "vitest"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function PageInfoProbe() {
|
||||
const info = usePageInfo()
|
||||
return <output>{info.title}</output>
|
||||
}
|
||||
|
||||
describe("page composition", () => {
|
||||
it("provides page information as component defaults", () => {
|
||||
const { rerender } = render(
|
||||
<PageContainer
|
||||
info={{
|
||||
breadcrumb: [
|
||||
{ label: "Products", href: "/products" },
|
||||
{ label: "Headphones" },
|
||||
],
|
||||
description: "Manage product details.",
|
||||
subtitle: "Catalog",
|
||||
title: "Headphones",
|
||||
}}
|
||||
>
|
||||
<PageInfoProbe />
|
||||
<PageBreadcrumb />
|
||||
<PageTitle />
|
||||
<PageSubtitle />
|
||||
<PageDescription />
|
||||
</PageContainer>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Headphones" })).toBeTruthy()
|
||||
expect(screen.getByText("Catalog")).toBeTruthy()
|
||||
expect(screen.getByText("Manage product details.")).toBeTruthy()
|
||||
expect(screen.getByRole("link", { name: "Products" })).toBeTruthy()
|
||||
expect(screen.getByRole("link", { name: "Headphones" })).toBeTruthy()
|
||||
expect(screen.getByRole("status").textContent).toBe("Headphones")
|
||||
|
||||
rerender(
|
||||
<PageContainer info={{ title: "Default title" }}>
|
||||
<PageBreadcrumb />
|
||||
<PageTitle>Explicit title</PageTitle>
|
||||
<PageSubtitle />
|
||||
<PageDescription />
|
||||
</PageContainer>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("heading").textContent).toBe("Explicit title")
|
||||
expect(document.querySelector('[data-slot="page-breadcrumb"]')).toBeNull()
|
||||
expect(document.querySelector('[data-slot="page-subtitle"]')).toBeNull()
|
||||
expect(document.querySelector('[data-slot="page-description"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("resolves partial page information through PagesManager", () => {
|
||||
const getPageInfo = vi.fn(
|
||||
(info?: Partial<PageInfo>): PageInfo => ({
|
||||
breadcrumb: undefined,
|
||||
description: "Managed description",
|
||||
subtitle: undefined,
|
||||
title: info?.title ?? "Managed title",
|
||||
})
|
||||
)
|
||||
|
||||
render(
|
||||
<PagesManager getPageInfo={getPageInfo}>
|
||||
<PageContainer info={{ title: "Local title" }}>
|
||||
<PageTitle />
|
||||
<PageDescription />
|
||||
</PageContainer>
|
||||
</PagesManager>
|
||||
)
|
||||
|
||||
expect(getPageInfo).toHaveBeenCalledWith({ title: "Local title" })
|
||||
expect(screen.getByRole("heading").textContent).toBe("Local title")
|
||||
expect(screen.getByText("Managed description")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders item paths and collapses middle breadcrumbs into a menu", async () => {
|
||||
render(
|
||||
<PageBreadcrumb
|
||||
items={[
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "Themes", href: "/themes" },
|
||||
{ label: "GitHub", href: "/github" },
|
||||
{ label: "Components", href: "/components" },
|
||||
{ label: "Breadcrumb" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("link", { name: "Home" })).toBeTruthy()
|
||||
expect(screen.getByRole("link", { name: "Components" })).toBeTruthy()
|
||||
expect(
|
||||
screen
|
||||
.getByRole("link", { name: "Breadcrumb" })
|
||||
.getAttribute("aria-current")
|
||||
).toBe("page")
|
||||
expect(screen.queryByText("Documentation")).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "More pages" }))
|
||||
expect(
|
||||
await screen.findByRole("menuitem", { name: "Documentation" })
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole("menuitem", { name: "Themes" })).toBeTruthy()
|
||||
expect(screen.getByRole("menuitem", { name: "GitHub" })).toBeTruthy()
|
||||
})
|
||||
|
||||
it("navigates back by default and supports a destination link", () => {
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {})
|
||||
const { rerender } = render(<PageBackButton />)
|
||||
|
||||
const button = screen.getByRole("button", { name: "Back" })
|
||||
expect(button.dataset.slot).toBe("page-back-button")
|
||||
fireEvent.click(button)
|
||||
expect(back).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(<PageBackButton href="/products">Products</PageBackButton>)
|
||||
const link = screen.getByRole("link", { name: "Products" })
|
||||
expect(link.getAttribute("href")).toBe("/products")
|
||||
expect(back).toHaveBeenCalledOnce()
|
||||
|
||||
back.mockRestore()
|
||||
})
|
||||
|
||||
it("renders semantic page structure and slots", () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageHeading>
|
||||
<PageHeadingExtra>Leading</PageHeadingExtra>
|
||||
<PageTitle>Products</PageTitle>
|
||||
<PageSubtitle>Catalog</PageSubtitle>
|
||||
<PageDescription>Manage the product catalog.</PageDescription>
|
||||
<PageHeadingExtra align="end">Status</PageHeadingExtra>
|
||||
<PageHeadingExtra align="end">Menu</PageHeadingExtra>
|
||||
</PageHeading>
|
||||
<PageHeaderExtra>Metadata</PageHeaderExtra>
|
||||
</PageHeader>
|
||||
<PageSection aria-label="Product list">Content</PageSection>
|
||||
</PageContainer>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 }).textContent).toBe(
|
||||
"Products"
|
||||
)
|
||||
expect(screen.getByRole("banner")).toBeTruthy()
|
||||
expect(screen.getByRole("region", { name: "Product list" })).toBeTruthy()
|
||||
expect(screen.getByText("Metadata").dataset.slot).toBe("page-header-extra")
|
||||
|
||||
const extras = document.querySelectorAll(
|
||||
'[data-slot="page-heading-extras"]'
|
||||
)
|
||||
expect(extras).toHaveLength(2)
|
||||
expect(extras[0]?.textContent).toBe("Leading")
|
||||
expect(extras[1]?.textContent).toBe("StatusMenu")
|
||||
})
|
||||
|
||||
it("composes triggers, an indicator, and matching content", () => {
|
||||
render(
|
||||
<PageTabs defaultValue="overview">
|
||||
<PageTabsList aria-label="Product sections">
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsTrigger value="inventory">Inventory</PageTabsTrigger>
|
||||
<PageTabsIndicator />
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="overview">Overview content</PageTabsContent>
|
||||
<PageTabsContent value="inventory">Inventory content</PageTabsContent>
|
||||
</PageTabs>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole("tablist", { name: "Product sections" })
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen
|
||||
.getByRole("tab", { name: "Overview" })
|
||||
.getAttribute("aria-selected")
|
||||
).toBe("true")
|
||||
expect(screen.getByText("Overview content")).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Inventory" }))
|
||||
expect(
|
||||
screen
|
||||
.getByRole("tab", { name: "Inventory" })
|
||||
.getAttribute("aria-selected")
|
||||
).toBe("true")
|
||||
expect(screen.getByText("Inventory content")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("selects the indicator appearance from the tabs variant", () => {
|
||||
const { rerender } = render(
|
||||
<PageTabs defaultValue="overview" variant="card">
|
||||
<PageTabsList aria-label="Product sections">
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsIndicator />
|
||||
</PageTabsList>
|
||||
</PageTabs>
|
||||
)
|
||||
|
||||
const indicator = document.querySelector(
|
||||
'[data-slot="page-tabs-indicator"]'
|
||||
)
|
||||
expect(indicator?.getAttribute("data-variant")).toBe("card")
|
||||
expect(indicator?.className).toContain("bg-card")
|
||||
|
||||
rerender(
|
||||
<PageTabs defaultValue="overview" variant="default">
|
||||
<PageTabsList aria-label="Product sections">
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsIndicator />
|
||||
</PageTabsList>
|
||||
</PageTabs>
|
||||
)
|
||||
expect(
|
||||
document
|
||||
.querySelector('[data-slot="page-tabs-indicator"]')
|
||||
?.getAttribute("data-variant")
|
||||
).toBe("default")
|
||||
expect(
|
||||
document.querySelector('[data-slot="page-tabs-triggers"]')?.className
|
||||
).toContain("bg-muted")
|
||||
})
|
||||
|
||||
it("derives orientation, side, and alignment from placement", () => {
|
||||
const { container } = render(
|
||||
<PageTabs defaultValue="overview" placement="left-center" variant="line">
|
||||
<PageTabsList aria-label="Product sections">
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsTrigger value="inventory">Inventory</PageTabsTrigger>
|
||||
<PageTabsIndicator />
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="overview">Content</PageTabsContent>
|
||||
</PageTabs>
|
||||
)
|
||||
|
||||
const root = container.querySelector('[data-slot="page-tabs"]')
|
||||
const positioner = container.querySelector(
|
||||
'[data-slot="page-tabs-positioner"]'
|
||||
)
|
||||
expect(root?.getAttribute("data-placement")).toBe("left-center")
|
||||
expect(root?.className).toContain("flex-row")
|
||||
expect(positioner?.className).toContain("flex-col")
|
||||
expect(positioner?.className).toContain("justify-center")
|
||||
expect(
|
||||
container.querySelector('[data-slot="page-tabs-indicator"]')?.className
|
||||
).toContain("right-0")
|
||||
})
|
||||
|
||||
it("groups multiple extras at each edge of the list", () => {
|
||||
const { container } = render(
|
||||
<PageTabs defaultValue="overview" placement="top-center">
|
||||
<PageTabsList aria-label="Product sections">
|
||||
<PageTabsExtra>Start one</PageTabsExtra>
|
||||
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
|
||||
<PageTabsExtra align="end">End one</PageTabsExtra>
|
||||
<PageTabsExtra>Start two</PageTabsExtra>
|
||||
<PageTabsExtra align="end">End two</PageTabsExtra>
|
||||
<PageTabsIndicator />
|
||||
</PageTabsList>
|
||||
</PageTabs>
|
||||
)
|
||||
|
||||
const extras = container.querySelectorAll('[data-slot="page-tabs-extras"]')
|
||||
expect(extras).toHaveLength(2)
|
||||
expect(extras[0]?.getAttribute("data-align")).toBe("start")
|
||||
expect(extras[0]?.textContent).toBe("Start oneStart two")
|
||||
expect(extras[1]?.getAttribute("data-align")).toBe("end")
|
||||
expect(extras[1]?.textContent).toBe("End oneEnd two")
|
||||
expect(
|
||||
container.querySelector('[data-slot="page-tabs-triggers"]')?.textContent
|
||||
).toBe("Overview")
|
||||
})
|
||||
|
||||
it("composes the workspace breadcrumb primitive", () => {
|
||||
render(
|
||||
<PageBreadcrumb aria-label="Location">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Products</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</PageBreadcrumb>
|
||||
)
|
||||
expect(screen.getByRole("navigation", { name: "Location" })).toBeTruthy()
|
||||
})
|
||||
|
||||
it("renders titles inline without a target and portals with a manager", () => {
|
||||
const titleContainer = document.createElement("div")
|
||||
document.body.append(titleContainer)
|
||||
|
||||
const { rerender } = render(
|
||||
<PageTitlePortal>
|
||||
<PageTitle>Inline title</PageTitle>
|
||||
</PageTitlePortal>
|
||||
)
|
||||
expect(screen.getByText("Inline title")).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<PagesManager titleContainer={titleContainer}>
|
||||
<PageTitlePortal>
|
||||
<PageTitle>Managed title</PageTitle>
|
||||
</PageTitlePortal>
|
||||
</PagesManager>
|
||||
)
|
||||
expect(titleContainer.textContent).toBe("Managed title")
|
||||
titleContainer.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
import * as React from "react"
|
||||
import { Button, type ButtonProps } from "@workspace/ui/components/button"
|
||||
import { ArrowLeftIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/ui/components/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import {
|
||||
isRenderablePageInfoValue,
|
||||
PageInfoContext,
|
||||
type PageInfo,
|
||||
useGetPageInfo,
|
||||
usePageInfo,
|
||||
} from "./page-info"
|
||||
|
||||
const pageBackMessage = /* i18n */ {
|
||||
id: "page.back.label",
|
||||
message: "Back",
|
||||
}
|
||||
|
||||
export interface PageContainerProps extends React.ComponentProps<"div"> {
|
||||
info?: Partial<PageInfo>
|
||||
}
|
||||
|
||||
export function PageContainer({
|
||||
children,
|
||||
className,
|
||||
info,
|
||||
...props
|
||||
}: PageContainerProps) {
|
||||
const inheritedInfo = usePageInfo()
|
||||
const getPageInfo = useGetPageInfo()
|
||||
const managedInfo = React.useMemo(
|
||||
() => getPageInfo?.(info),
|
||||
[getPageInfo, info]
|
||||
)
|
||||
const resolvedInfo = React.useMemo(
|
||||
() => ({ ...inheritedInfo, ...(managedInfo ?? info) }),
|
||||
[info, inheritedInfo, managedInfo]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageInfoContext.Provider value={resolvedInfo}>
|
||||
<div
|
||||
data-slot="page-container"
|
||||
className={cn("flex min-w-0 flex-col gap-6", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PageInfoContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
data-slot="page-header"
|
||||
className={cn("flex min-w-0 flex-col gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type PageBackButtonProps = ButtonProps & {
|
||||
/** Uses a native anchor. Use `render` instead when integrating a router Link. */
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function PageBackButton({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
render,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: PageBackButtonProps) {
|
||||
const defaultLabel = useMessage(pageBackMessage)
|
||||
const renderedElement = render ?? (href ? <a href={href} /> : undefined)
|
||||
|
||||
function handleClick(
|
||||
event: Parameters<NonNullable<ButtonProps["onClick"]>>[0]
|
||||
) {
|
||||
onClick?.(event)
|
||||
|
||||
if (
|
||||
!event.defaultPrevented &&
|
||||
!href &&
|
||||
render === undefined &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="page-back-button"
|
||||
className={cn("w-fit", className)}
|
||||
render={renderedElement}
|
||||
size={size}
|
||||
type={renderedElement ? undefined : "button"}
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon data-icon="inline-start" className="rtl:rotate-180" />
|
||||
{children ?? defaultLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeading({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const childGroups = groupPageHeadingChildren(children)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="page-heading"
|
||||
className={cn(
|
||||
"grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-start",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PageHeadingExtras align="start">{childGroups.start}</PageHeadingExtras>
|
||||
<div
|
||||
data-slot="page-heading-content"
|
||||
className="min-w-0 space-y-1.5 sm:col-start-2 sm:row-start-1"
|
||||
>
|
||||
{childGroups.content}
|
||||
</div>
|
||||
<PageHeadingExtras align="end">{childGroups.end}</PageHeadingExtras>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type PageHeadingExtraAlign = "start" | "end"
|
||||
|
||||
export interface PageHeadingExtraProps extends React.ComponentProps<"div"> {
|
||||
align?: PageHeadingExtraAlign
|
||||
}
|
||||
|
||||
export function PageHeadingExtra({
|
||||
align = "start",
|
||||
className,
|
||||
...props
|
||||
}: PageHeadingExtraProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="page-heading-extra"
|
||||
data-align={align}
|
||||
className={cn("flex min-w-0 flex-wrap items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageTitle({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"h1">) {
|
||||
const pageInfo = usePageInfo()
|
||||
const content = children === undefined ? pageInfo.title : children
|
||||
if (!isRenderablePageInfoValue(content)) return null
|
||||
|
||||
return (
|
||||
<h1
|
||||
data-slot="page-title"
|
||||
className={cn(
|
||||
"font-heading text-2xl leading-tight font-semibold tracking-tight text-balance sm:text-3xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSubtitle({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
const pageInfo = usePageInfo()
|
||||
const content = children === undefined ? pageInfo.subtitle : children
|
||||
if (!isRenderablePageInfoValue(content)) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="page-subtitle"
|
||||
className={cn(
|
||||
"text-xs font-medium tracking-wide text-muted-foreground uppercase",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageDescription({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
const pageInfo = usePageInfo()
|
||||
const content = children === undefined ? pageInfo.description : children
|
||||
if (!isRenderablePageInfoValue(content)) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="page-description"
|
||||
className={cn(
|
||||
"max-w-3xl text-sm leading-relaxed text-pretty text-muted-foreground sm:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeaderExtra({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="page-header-extra"
|
||||
className={cn("min-w-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSection({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"section">) {
|
||||
return (
|
||||
<section
|
||||
data-slot="page-section"
|
||||
className={cn("min-w-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PageHeadingExtras({
|
||||
align,
|
||||
children,
|
||||
}: {
|
||||
align: PageHeadingExtraAlign
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="page-heading-extras"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-wrap items-center gap-2 empty:hidden sm:row-start-1",
|
||||
align === "start" && "sm:col-start-1",
|
||||
align === "end" && "justify-end sm:col-start-3"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupPageHeadingChildren(children: React.ReactNode) {
|
||||
const groups: {
|
||||
content: React.ReactNode[]
|
||||
end: React.ReactNode[]
|
||||
start: React.ReactNode[]
|
||||
} = {
|
||||
content: [],
|
||||
end: [],
|
||||
start: [],
|
||||
}
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (isPageHeadingExtra(child)) {
|
||||
groups[child.props.align === "end" ? "end" : "start"].push(child)
|
||||
} else {
|
||||
groups.content.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function isPageHeadingExtra(
|
||||
child: React.ReactNode
|
||||
): child is React.ReactElement<PageHeadingExtraProps> {
|
||||
return (
|
||||
React.isValidElement<PageHeadingExtraProps>(child) &&
|
||||
child.type === PageHeadingExtra
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@source "../";
|
||||
@@ -0,0 +1,355 @@
|
||||
import * as React from "react"
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
export type PageTabsSide = "top" | "right" | "bottom" | "left"
|
||||
export type PageTabsAlignment = "start" | "center" | "end"
|
||||
export type PageTabsPlacement = `${PageTabsSide}-${PageTabsAlignment}`
|
||||
export type PageTabsVariant = "line" | "card" | "default"
|
||||
export type PageTabsExtraAlign = "start" | "end"
|
||||
|
||||
type PageTabsAxis = "inline" | "block"
|
||||
|
||||
type PageTabsContextValue = {
|
||||
alignment: PageTabsAlignment
|
||||
axis: PageTabsAxis
|
||||
side: PageTabsSide
|
||||
variant: PageTabsVariant
|
||||
}
|
||||
|
||||
const PageTabsContext = React.createContext<PageTabsContextValue>({
|
||||
alignment: "start",
|
||||
axis: "inline",
|
||||
side: "top",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
export interface PageTabsProps extends Omit<
|
||||
TabsPrimitive.Root.Props,
|
||||
"orientation"
|
||||
> {
|
||||
placement?: PageTabsPlacement
|
||||
variant?: PageTabsVariant
|
||||
}
|
||||
|
||||
export function PageTabs({
|
||||
className,
|
||||
placement = "top-start",
|
||||
variant = "default",
|
||||
...props
|
||||
}: PageTabsProps) {
|
||||
const { alignment, axis, side } = resolvePageTabsPlacement(placement)
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ alignment, axis, side, variant }),
|
||||
[alignment, axis, side, variant]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
data-slot="page-tabs"
|
||||
data-placement={placement}
|
||||
data-variant={variant}
|
||||
orientation={axis === "inline" ? "horizontal" : "vertical"}
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0",
|
||||
side === "top" && "flex-col",
|
||||
side === "right" && "flex-row-reverse",
|
||||
side === "bottom" && "flex-col-reverse",
|
||||
side === "left" && "flex-row",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PageTabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PageTabsListProps = TabsPrimitive.List.Props
|
||||
|
||||
export function PageTabsList({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PageTabsListProps) {
|
||||
const { alignment, axis, side, variant } = React.useContext(PageTabsContext)
|
||||
const inline = axis === "inline"
|
||||
const childGroups = groupPageTabsListChildren(children)
|
||||
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="page-tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 shrink-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
inline
|
||||
? "grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center overflow-x-auto"
|
||||
: "grid h-full grid-rows-[auto_minmax(0,1fr)_auto] overflow-y-auto",
|
||||
variant === "card" && side === "top" && "border-b",
|
||||
variant === "card" && side === "right" && "border-l",
|
||||
variant === "card" && side === "bottom" && "border-t",
|
||||
variant === "card" && side === "left" && "border-r",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PageTabsExtras align="start">{childGroups.start}</PageTabsExtras>
|
||||
<div
|
||||
data-slot="page-tabs-positioner"
|
||||
className={cn(
|
||||
inline
|
||||
? "flex w-max min-w-full items-center"
|
||||
: "flex h-max min-h-full w-full flex-col",
|
||||
alignment === "start" && "justify-start",
|
||||
alignment === "center" && "justify-center",
|
||||
alignment === "end" && "justify-end"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-slot="page-tabs-triggers"
|
||||
className={cn(
|
||||
inline
|
||||
? "relative isolate flex items-center"
|
||||
: "relative isolate flex w-full flex-col",
|
||||
variant === "line" ? (inline ? "gap-6" : "gap-1") : "gap-1",
|
||||
variant === "default" && "rounded-xl bg-muted p-1"
|
||||
)}
|
||||
>
|
||||
{childGroups.triggers}
|
||||
</div>
|
||||
</div>
|
||||
<PageTabsExtras align="end">{childGroups.end}</PageTabsExtras>
|
||||
{childGroups.indicators}
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
}
|
||||
|
||||
export type PageTabsTriggerProps = TabsPrimitive.Tab.Props
|
||||
|
||||
export function PageTabsTrigger({ className, ...props }: PageTabsTriggerProps) {
|
||||
const { axis, side, variant } = React.useContext(PageTabsContext)
|
||||
const inline = axis === "inline"
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="page-tabs-trigger"
|
||||
className={cn(
|
||||
"relative z-10 inline-flex shrink-0 items-center text-sm font-medium text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:text-foreground disabled:pointer-events-none disabled:opacity-50",
|
||||
inline
|
||||
? "h-10 justify-center whitespace-nowrap"
|
||||
: "min-h-10 w-full justify-start px-3 text-start",
|
||||
inline && variant !== "line" && "px-4",
|
||||
variant === "line" && "data-active:text-foreground",
|
||||
variant === "card" &&
|
||||
getCardPageTabsClassName(
|
||||
side,
|
||||
"bg-muted/20 not-data-active:hover:bg-muted data-active:border-transparent data-active:bg-transparent data-active:text-primary"
|
||||
),
|
||||
variant === "default" && "rounded-lg px-3 data-active:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type PageTabsIndicatorProps = TabsPrimitive.Indicator.Props
|
||||
|
||||
export function PageTabsIndicator({
|
||||
className,
|
||||
...props
|
||||
}: PageTabsIndicatorProps) {
|
||||
const { side, variant } = React.useContext(PageTabsContext)
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Indicator
|
||||
data-slot="page-tabs-indicator"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"pointer-events-none absolute transition-[top,left,width,height]",
|
||||
variant === "line" && getLinePageTabsIndicatorClassName(side),
|
||||
variant !== "line" &&
|
||||
"top-(--active-tab-top) left-(--active-tab-left) h-(--active-tab-height) w-(--active-tab-width)",
|
||||
variant === "card" &&
|
||||
getActiveCardPageTabsIndicatorClassName(
|
||||
side,
|
||||
"z-0 bg-card shadow-sm"
|
||||
),
|
||||
variant === "default" && "z-0 rounded-lg bg-background shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PageTabsExtraProps extends React.ComponentProps<"div"> {
|
||||
align?: PageTabsExtraAlign
|
||||
}
|
||||
|
||||
export function PageTabsExtra({
|
||||
align = "start",
|
||||
className,
|
||||
...props
|
||||
}: PageTabsExtraProps) {
|
||||
const { axis } = React.useContext(PageTabsContext)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="page-tabs-extra"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"shrink-0 items-center gap-2",
|
||||
axis === "inline" ? "inline-flex" : "flex w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type PageTabsContentProps = TabsPrimitive.Panel.Props
|
||||
|
||||
export function PageTabsContent({ className, ...props }: PageTabsContentProps) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="page-tabs-content"
|
||||
className={cn("min-h-0 min-w-0 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PageTabsExtras({
|
||||
align,
|
||||
children,
|
||||
}: {
|
||||
align: PageTabsExtraAlign
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { axis } = React.useContext(PageTabsContext)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="page-tabs-extras"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"shrink-0 gap-2",
|
||||
axis === "inline" ? "inline-flex items-center" : "flex w-full flex-col"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getLinePageTabsIndicatorClassName(side: PageTabsSide) {
|
||||
if (side === "top") {
|
||||
return "bottom-0 left-(--active-tab-left) h-0.5 w-(--active-tab-width) bg-foreground"
|
||||
}
|
||||
if (side === "right") {
|
||||
return "top-(--active-tab-top) left-0 h-(--active-tab-height) w-0.5 bg-foreground"
|
||||
}
|
||||
if (side === "bottom") {
|
||||
return "top-0 left-(--active-tab-left) h-0.5 w-(--active-tab-width) bg-foreground"
|
||||
}
|
||||
return "top-(--active-tab-top) right-0 h-(--active-tab-height) w-0.5 bg-foreground"
|
||||
}
|
||||
|
||||
function getCardPageTabsClassName(side: PageTabsSide, className: string) {
|
||||
if (side === "top") {
|
||||
return cn("rounded-t-xl border border-b-0", className)
|
||||
}
|
||||
if (side === "right") {
|
||||
return cn("rounded-r-xl border border-l-0", className)
|
||||
}
|
||||
if (side === "bottom") {
|
||||
return cn("rounded-b-xl border border-t-0", className)
|
||||
}
|
||||
return cn("rounded-l-xl border border-r-0", className)
|
||||
}
|
||||
|
||||
function getActiveCardPageTabsIndicatorClassName(
|
||||
side: PageTabsSide,
|
||||
className: string
|
||||
) {
|
||||
if (side === "top") {
|
||||
return cn(
|
||||
getCardPageTabsClassName(side, className),
|
||||
"after:absolute after:-bottom-px after:left-0 after:h-0.5 after:w-full after:bg-card"
|
||||
)
|
||||
}
|
||||
if (side === "right") {
|
||||
return cn(
|
||||
getCardPageTabsClassName(side, className),
|
||||
"after:absolute after:top-0 after:-left-px after:h-full after:w-0.5 after:bg-card"
|
||||
)
|
||||
}
|
||||
if (side === "bottom") {
|
||||
return cn(
|
||||
getCardPageTabsClassName(side, className),
|
||||
"after:absolute after:-top-px after:left-0 after:h-0.5 after:w-full after:bg-card"
|
||||
)
|
||||
}
|
||||
return cn(
|
||||
getCardPageTabsClassName(side, className),
|
||||
"after:absolute after:top-0 after:-right-px after:h-full after:w-0.5 after:bg-card"
|
||||
)
|
||||
}
|
||||
|
||||
function groupPageTabsListChildren(children: React.ReactNode) {
|
||||
const groups: {
|
||||
end: React.ReactNode[]
|
||||
indicators: React.ReactNode[]
|
||||
start: React.ReactNode[]
|
||||
triggers: React.ReactNode[]
|
||||
} = {
|
||||
end: [],
|
||||
indicators: [],
|
||||
start: [],
|
||||
triggers: [],
|
||||
}
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (isPageTabsExtra(child)) {
|
||||
groups[child.props.align === "end" ? "end" : "start"].push(child)
|
||||
} else if (isPageTabsIndicator(child)) {
|
||||
groups.indicators.push(child)
|
||||
} else {
|
||||
groups.triggers.push(child)
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function isPageTabsExtra(
|
||||
child: React.ReactNode
|
||||
): child is React.ReactElement<PageTabsExtraProps> {
|
||||
return (
|
||||
React.isValidElement<PageTabsExtraProps>(child) &&
|
||||
child.type === PageTabsExtra
|
||||
)
|
||||
}
|
||||
|
||||
function isPageTabsIndicator(
|
||||
child: React.ReactNode
|
||||
): child is React.ReactElement<PageTabsIndicatorProps> {
|
||||
return (
|
||||
React.isValidElement<PageTabsIndicatorProps>(child) &&
|
||||
child.type === PageTabsIndicator
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePageTabsPlacement(placement: PageTabsPlacement) {
|
||||
const [side, alignment] = placement.split("-") as [
|
||||
PageTabsSide,
|
||||
PageTabsAlignment,
|
||||
]
|
||||
|
||||
return {
|
||||
alignment,
|
||||
axis: side === "top" || side === "bottom" ? "inline" : "block",
|
||||
side,
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@workspace/page": ["./src/index.tsx"],
|
||||
"@workspace/ui/*": ["../ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user