"use client";
import { ChevronRight, Ellipsis } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useIsPresent,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
Children,
forwardRef,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
useId,
type ComponentPropsWithRef,
type ReactElement,
} from "react";
import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger } from "@/components/motion/popover-morph";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BreadcrumbProps = ComponentPropsWithRef<"nav">;
/** A navigation landmark. Keep it mounted while the route changes. */
export function Breadcrumb({ className, children, ...props }: BreadcrumbProps) {
const id = useId();
return (
);
}
export type BreadcrumbListProps = ComponentPropsWithRef<"ol"> & {
/** Maximum visible slots, including the ellipsis. Minimum 3; Infinity disables collapsing. */
maxItems?: number;
/** Accessible label for the hidden ancestor disclosure. */
overflowLabel?: string;
};
/** Pass keyed BreadcrumbItems directly so entering and leaving routes animate. */
export function BreadcrumbList({ className, children, maxItems = 4, overflowLabel = "Show hidden paths", ...props }: BreadcrumbListProps) {
const items = Children.toArray(children);
const limit = Number.isFinite(maxItems) ? Math.max(3, Math.floor(maxItems)) : 4;
const collapse = maxItems !== Infinity && items.length > limit;
const tailCount = limit - 2;
const visible = collapse ? [
items[0],
{items.slice(1, -tailCount)}
,
...items.slice(-tailCount),
] : items;
return (
{visible}
);
}
export type BreadcrumbItemProps = HTMLMotionProps<"li">;
/** Use a stable route key; put its optional separator inside this item. */
export const BreadcrumbItem = forwardRef(
function BreadcrumbItem({ className, style, children, ...props }, ref) {
const reduce = useReducedMotion();
const present = useIsPresent();
const itemRef = useRef(null);
useLayoutEffect(() => {
const item = itemRef.current;
if (!item || !present) return;
const measure = () => {
// popLayout snapshots offsetWidth (integer pixels). Retain the exact
// width so a fractional-pixel loss cannot wrap the final character.
item.style.setProperty("--breadcrumb-exit-width", `${item.getBoundingClientRect().width}px`);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(item);
return () => observer.disconnect();
}, [present]);
const hidden = { opacity: 0, y: reduce ? 0 : 6 };
return (
{
itemRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
layout={reduce ? false : "position"}
initial={hidden}
animate={{ opacity: 1, y: 0 }}
exit={hidden}
transition={{ duration: 0.2, ease: EASE_OUT, layout: SPRING_LAYOUT }}
{...props}
inert={!present}
aria-hidden={!present || undefined}
style={{
...style,
minWidth: present ? style?.minWidth : "var(--breadcrumb-exit-width)",
pointerEvents: present ? style?.pointerEvents : "none",
}}
className={cn("relative inline-flex min-w-0 max-w-full items-center gap-1", className)}
>
{children}
);
},
);
export type BreadcrumbLinkProps = ComponentPropsWithRef<"a"> & {
/** Render your router's Link, spreading these props onto it. */
render?: (props: ComponentPropsWithRef<"a">) => ReactElement;
};
export function BreadcrumbLink({ className, render, ...props }: BreadcrumbLinkProps) {
const linkProps = {
...props,
className: cn(
"inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-muted-foreground transition-colors duration-150 hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&>svg]:size-3.5 [&>svg]:shrink-0",
className,
),
};
return render ? render(linkProps) : ;
}
export type BreadcrumbPageProps = ComponentPropsWithRef<"span">;
export function BreadcrumbPage({ className, children, ...props }: BreadcrumbPageProps) {
return (
svg]:size-3.5 [&>svg]:shrink-0",
className,
)}
>
{children}
);
}
export type BreadcrumbSeparatorProps = ComponentPropsWithRef<"span">;
/** Decorative separator, placed inside the following BreadcrumbItem. */
export function BreadcrumbSeparator({ className, children, ...props }: BreadcrumbSeparatorProps) {
return (
svg]:size-3.5 rtl:rotate-180", className)}
>
{children ?? }
);
}
export interface BreadcrumbEllipsisProps {
/** Hidden BreadcrumbItems, in path order. */
children: ReactNode;
className?: string;
label?: string;
}
/** Hover disclosure with click/touch toggle and keyboard access to ancestor links. */
export function BreadcrumbEllipsis({ children, className, label = "Show hidden paths" }: BreadcrumbEllipsisProps) {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<{ align: "start" | "end"; side: "top" | "bottom"; width: number }>({ align: "start", side: "bottom", width: 224 });
const canHover = useHoverCapable();
const present = useIsPresent();
const trigger = useRef(null);
const panel = useRef(null);
const focusOnOpen = useRef(false);
const closeTimer = useRef | null>(null);
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const rect = trigger.current?.getBoundingClientRect();
if (!rect) return;
const right = window.innerWidth - rect.left - 8;
const left = rect.right - 8;
const align = right < 224 && left > right ? "end" : "start";
const below = window.innerHeight - rect.bottom;
setPlacement({
align,
side: below < 280 && rect.top > below ? "top" : "bottom",
width: Math.max(32, Math.min(224, align === "start" ? right : left)),
});
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [open]);
const cancelClose = () => {
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
closeTimer.current = null;
};
const leave = () => {
cancelClose();
// Allow the pointer to cross the gap between the trigger and portal.
closeTimer.current = setTimeout(() => {
if (!panel.current?.contains(document.activeElement) && document.activeElement !== trigger.current) setOpen(false);
}, 160);
};
useEffect(() => () => {
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
}, []);
useEffect(() => {
if (!open) return;
const onFocus = (event: FocusEvent) => {
if (event.target instanceof Node && event.target !== trigger.current && !panel.current?.contains(event.target)) setOpen(false);
};
document.addEventListener("focusin", onFocus);
return () => document.removeEventListener("focusin", onFocus);
}, [open]);
return (
{
cancelClose();
if (canHover && event.pointerType === "mouse") {
focusOnOpen.current = false;
setOpen(true);
}
}}
onPointerLeave={leave}
>
{ cancelClose(); setOpen(true); }}
onPointerLeave={leave}
onClick={(event) => {
if ((event.target as Element).closest("a[href]")) setOpen(false);
}}
>
{children}
);
}
function BreadcrumbOverflowPaths({ focusOnOpen, ref, ...props }: ComponentPropsWithRef<"ol"> & { focusOnOpen: { current: boolean } }) {
const localRef = useRef(null);
useLayoutEffect(() => {
if (!focusOnOpen.current) return;
const focus = () => localRef.current?.querySelector("a[href],button")?.focus();
focus();
// The portal becomes visible after its parent's layout measurement.
const frame = requestAnimationFrame(() => {
focus();
focusOnOpen.current = false;
});
return () => cancelAnimationFrame(frame);
}, [focusOnOpen]);
return (
{
localRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
className="flex max-h-64 flex-col gap-0.5 overflow-y-auto [&>li]:w-full [&_a]:w-full [&_a]:py-1 [&_a]:[overflow-wrap:anywhere] [&_[data-breadcrumb-separator]]:hidden"
/>
);
}