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.
@@ -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` 导入。
+37
View File
@@ -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` 不保存页面栈,只向子树提供标题容器。
+28
View File
@@ -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"
}
}
+215
View File
@@ -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)
}
+47
View File
@@ -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"
+66
View File
@@ -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)
}
+45
View File
@@ -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
}
+342
View File
@@ -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()
})
})
+312
View File
@@ -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
)
}
+1
View File
@@ -0,0 +1 @@
@source "../";
+355
View File
@@ -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
}
+19
View File
@@ -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"]
}