Files
simple-react-app-kit/packages/page/src/breadcrumb.tsx
T
2026-09-28 23:13:49 +08:00

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