"use client"; import { ChevronRightIcon, EllipsisIcon, FolderIcon, PencilIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useLayoutEffect, useRef, useState } from "react"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu"; import { cn } from "@/lib/utils"; export interface Crumb { id: string; label: string; icon?: ReactNode; } export interface OverflowBreadcrumbProps { segments: Crumb[]; onNavigate?: (crumb: Crumb, index: number) => void; onRename?: (label: string) => void; maxCurrentWidth?: number; className?: string; } const SEP = 20; const MORE = 28; export function OverflowBreadcrumb({ segments, onNavigate, onRename, maxCurrentWidth = 200, className }: OverflowBreadcrumbProps) { const reduce = useReducedMotion(); const navRef = useRef(null); const measureRef = useRef(null); const [width, setWidth] = useState(0); const [sizes, setSizes] = useState([]); const [editing, setEditing] = useState(false); useLayoutEffect(() => { const el = navRef.current; if (!el) return; const ro = new ResizeObserver(() => setWidth(el.clientWidth)); ro.observe(el); setWidth(el.clientWidth); return () => ro.disconnect(); }, []); // Measure every crumb once, offstage, so collapsing never depends on what is currently shown. useLayoutEffect(() => { const row = measureRef.current; if (!row) return; setSizes(Array.from(row.children, (c) => (c as HTMLElement).offsetWidth)); }, [segments.map((s) => s.label).join("\u0000")]); const n = segments.length; const lastIndex = n - 1; let shown = segments.map((_, i) => i); if (sizes.length === n && width > 0 && n > 2) { const current = Math.min(sizes[lastIndex], maxCurrentWidth) + (onRename ? 18 : 0); const all = current + sizes.slice(0, lastIndex).reduce((a, b) => a + b + SEP, 0); if (all > width) { let budget = width - current - MORE - SEP; const keep = new Set([lastIndex]); if (sizes[0] + SEP <= budget) { keep.add(0); budget -= sizes[0] + SEP; } for (let i = lastIndex - 1; i >= 1; i--) { if (sizes[i] + SEP > budget) break; keep.add(i); budget -= sizes[i] + SEP; } shown = [...keep].sort((a, b) => a - b); } } const hidden = segments.map((_, i) => i).filter((i) => i !== lastIndex && !shown.includes(i)); const moreAt = hidden.length ? (shown[0] === 0 ? 1 : 0) : -1; type Slot = { key: string; kind: "crumb"; index: number } | { key: string; kind: "more" }; const slots: Slot[] = shown.map((i) => ({ key: segments[i].id, kind: "crumb" as const, index: i })); if (moreAt >= 0) slots.splice(moreAt, 0, { key: "__more", kind: "more" }); const ease = [0.23, 1, 0.32, 1] as const; const item = { layout: reduce ? false : ("position" as const), initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.96, transition: { duration: 0.1 } }, transition: { duration: 0.18, ease }, }; return ( ); } function CurrentCrumb({ crumb, editing, maxWidth, onEdit, onRename, }: { crumb: Crumb; editing: boolean; maxWidth: number; onEdit?: (on: boolean) => void; onRename: (label: string) => void; }) { const inputRef = useRef(null); const buttonRef = useRef(null); const [draft, setDraft] = useState(crumb.label); const cancelled = useRef(false); useLayoutEffect(() => { if (editing) { setDraft(crumb.label); cancelled.current = false; inputRef.current?.focus(); inputRef.current?.select(); } }, [editing, crumb.label]); const finish = (commit: boolean) => { cancelled.current = true; onRename(commit ? draft.trim() : crumb.label); requestAnimationFrame(() => buttonRef.current?.focus()); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); finish(true); } else if (event.key === "Escape") { event.preventDefault(); finish(false); } }; if (editing) { return ( !cancelled.current && finish(true)} onChange={(e) => setDraft(e.target.value)} onKeyDown={onKey} ref={inputRef} style={{ maxWidth }} value={draft} /> ); } const content = ( <> {crumb.icon} {crumb.label} ); if (!onEdit) { return ( {content} ); } return ( ); }