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)
|
||||
}
|
||||
Reference in New Issue
Block a user