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
+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
}