/** * Breadcrumb — the trail of where you are in a hierarchy. * * A row of links back up the tree, ending in the page you are on. The last * crumb is not a link: you cannot navigate to where you already are, so it is * a plain, current-marked label rather than a dead tappable target. * * The separators are the component's job, not yours. `Breadcrumb.List` drops a * chevron between each crumb it holds, so a trail is just its items — there is * no separator to forget, mis-order, or leave dangling at the end. Change the * glyph once with `separator` on the root and every gap follows. * * ```tsx * * * * Home * * * Projects * * * PanelUI * * * * ``` * * A deep trail on a narrow phone does not wrap into a paragraph: give the list * a `maxItems` and it keeps the first and last crumbs, folding the middle into * a single ellipsis. Hand the ellipsis an `onEllipsisPress` and it becomes the * handle for a menu of the hidden steps. */ import { Children, createContext, forwardRef, isValidElement, useContext, type ReactNode, } from 'react'; import { View, type ViewProps } from 'react-native'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { ChevronRightIcon, EllipsisIcon } from '../../icons'; import { AnimatedPressable, type AnimatedPressableProps, } from '../../primitives/animated-pressable'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; type BreadcrumbSize = 'sm' | 'default'; const crumbText = tv({ base: '', variants: { size: { sm: 'text-xs', default: 'text-sm', }, }, defaultVariants: { size: 'default' }, }); /** * Sub-parts read the density and the shared separator glyph from here, so the * trail is configured once on the root rather than repeated per crumb. */ const BreadcrumbContext = createContext<{ size: BreadcrumbSize; separator: ReactNode; }>({ size: 'default', separator: null, }); const useBreadcrumb = () => useContext(BreadcrumbContext); export interface BreadcrumbProps extends ViewProps { className?: string; /** Text density for every crumb. `sm` for a dense header bar. */ size?: BreadcrumbSize; /** * The glyph `Breadcrumb.List` places between crumbs. Defaults to a chevron; * pass a `/`, a slash, or any node to change every gap at once. */ separator?: ReactNode; children?: ReactNode; } /** * The trail's landmark. Labelled for assistive tech so the row is announced as * "Breadcrumb" rather than an anonymous run of links. */ const BreadcrumbRoot = forwardRef( ({ className, size = 'default', separator, children, ...props }, ref) => ( {textChildren(children)} ) ); BreadcrumbRoot.displayName = 'Breadcrumb'; export interface BreadcrumbListProps extends ViewProps { className?: string; /** * Collapse the trail once it holds more than this many crumbs, so a deep * path never wraps into a block of text on a narrow screen. The first * `itemsBeforeCollapse` and last `itemsAfterCollapse` survive; the middle * folds into a single ellipsis. */ maxItems?: number; /** How many leading crumbs to keep when collapsing. Default 1. */ itemsBeforeCollapse?: number; /** How many trailing crumbs to keep when collapsing. Default 1. */ itemsAfterCollapse?: number; /** * Makes the collapsed ellipsis pressable — the handle for a menu listing the * hidden steps. Without it the ellipsis is a static marker. */ onEllipsisPress?: () => void; children?: ReactNode; } /** A private marker: the slot the collapsed middle crumbs fold into. */ const COLLAPSE = Symbol('breadcrumb-collapse'); /** * The crumb row. It owns the separators — one between every pair of crumbs and * none at the ends — and the collapsing, so a caller only ever lists items. */ const BreadcrumbList = forwardRef( ( { className, maxItems, itemsBeforeCollapse = 1, itemsAfterCollapse = 1, onEllipsisPress, children, ...props }, ref ) => { const crumbs = Children.toArray(children).filter(isValidElement); // Fold the middle only when hiding something actually shortens the row: // keeping N-1 of N crumbs plus an ellipsis saves nothing. let sequence: Array = crumbs; if ( maxItems !== undefined && crumbs.length > maxItems && itemsBeforeCollapse + itemsAfterCollapse < crumbs.length ) { const head = crumbs.slice(0, Math.max(0, itemsBeforeCollapse)); const tail = itemsAfterCollapse > 0 ? crumbs.slice(crumbs.length - itemsAfterCollapse) : []; sequence = [...head, COLLAPSE, ...tail]; } return ( {sequence.map((node, i) => ( {i > 0 ? : null} {node === COLLAPSE ? ( ) : ( node )} ))} ); } ); BreadcrumbList.displayName = 'Breadcrumb.List'; export interface BreadcrumbItemProps extends ViewProps { className?: string; children?: ReactNode; } /** One crumb: wraps a link or the current page. */ const BreadcrumbItem = forwardRef( ({ className, children, ...props }, ref) => ( {textChildren(children)} ) ); BreadcrumbItem.displayName = 'Breadcrumb.Item'; export interface BreadcrumbLinkProps extends Omit { className?: string; /** Text style for the crumb's label. */ textClassName?: string; children?: ReactNode; } /** * A navigable crumb — an ancestor you can jump back to. Muted until pressed, * so the current page (which is not a link) reads as the emphasised end of the * trail. A string child is wrapped in the crumb text style; anything else * renders as given. */ const BreadcrumbLink = forwardRef( ({ className, textClassName, children, ...props }, ref) => { const { size } = useBreadcrumb(); return ( {textChildren(children, (text) => ( {text} ))} ); } ); BreadcrumbLink.displayName = 'Breadcrumb.Link'; export interface BreadcrumbPageProps extends TextProps { className?: string; } /** * The trailing crumb: where you are now. Not a link — `aria-current="page"` * marks it as the destination, and it is painted in the full foreground so the * trail resolves to it. */ const BreadcrumbPage = forwardRef, BreadcrumbPageProps>( ({ className, ...props }, ref) => { const { size } = useBreadcrumb(); return ( ); } ); BreadcrumbPage.displayName = 'Breadcrumb.Page'; export interface BreadcrumbSeparatorProps extends ViewProps { className?: string; /** Override the glyph for this one gap. Falls back to the root's separator. */ children?: ReactNode; } /** * The glyph between crumbs. `Breadcrumb.List` inserts it for you; it is public * only for the rare hand-assembled trail. Hidden from screen readers — the * order of the crumbs already conveys the hierarchy. */ const BreadcrumbSeparator = forwardRef( ({ className, children, ...props }, ref) => { const { separator } = useBreadcrumb(); const rawTint = useCSSVariable('--color-muted-foreground'); const tint = typeof rawTint === 'string' ? rawTint : undefined; const glyph = children ?? separator; return ( {glyph ?? } ); } ); BreadcrumbSeparator.displayName = 'Breadcrumb.Separator'; export interface BreadcrumbEllipsisProps extends Omit { className?: string; } /** * Stands in for the crumbs a collapsed trail hides. Static by default; give it * an `onPress` (via the list's `onEllipsisPress`) and it becomes the trigger * for a menu of the hidden steps. Labelled "Show more" so the collapse is not * silent to assistive tech. */ const BreadcrumbEllipsis = forwardRef( ({ className, onPress, ...props }, ref) => { const rawTint = useCSSVariable('--color-muted-foreground'); const tint = typeof rawTint === 'string' ? rawTint : undefined; const glyph = ; if (!onPress) { return ( {glyph} ); } return ( {glyph} ); } ); BreadcrumbEllipsis.displayName = 'Breadcrumb.Ellipsis'; export const Breadcrumb = Object.assign(BreadcrumbRoot, { List: BreadcrumbList, Item: BreadcrumbItem, Link: BreadcrumbLink, Page: BreadcrumbPage, Separator: BreadcrumbSeparator, Ellipsis: BreadcrumbEllipsis, });