/**
* 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,
});