feat(page): add composable page primitives
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user