216 lines
5.3 KiB
TypeScript
216 lines
5.3 KiB
TypeScript
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)
|
|
}
|