{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "smart-breadcrumbs",
  "type": "registry:ui",
  "title": "Smart Breadcrumbs",
  "description": "Breadcrumbs that measure themselves and fold middle items into an accessible overflow menu when space runs short.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/smart-breadcrumbs.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface BreadcrumbItem {\n  /** Stable key; defaults to href or label. */\n  id?: string;\n  label: string;\n  href?: string;\n  /** Optional leading icon (decorative). */\n  icon?: React.ReactNode;\n}\n\nexport interface SmartBreadcrumbsProps extends Omit<React.HTMLAttributes<HTMLElement>, \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"> {\n  /** Trail from the root to the current page. The last item is the current page. */\n  items: BreadcrumbItem[];\n  /** Items at the start that never collapse. */\n  keepFirst?: number;\n  /** Items at the end (including the current page) that never collapse. */\n  keepLast?: number;\n  separator?: \"chevron\" | \"slash\" | \"dot\";\n  size?: \"sm\" | \"md\";\n  /** Spring for the collapse / expand layout animation. */\n  spring?: Spring;\n  /** Accent for hover underlines, focus rings and the open overflow button. */\n  accent?: string;\n  /** Accessible name of the nav landmark. */\n  label?: string;\n  /** Accessible name of the overflow (\"…\") button. */\n  overflowLabel?: string;\n  /** Called when a crumb or overflow menu item is activated (before navigation). */\n  onNavigate?: (item: BreadcrumbItem, event: React.MouseEvent) => void;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst keyOf = (item: BreadcrumbItem, index: number) => item.id ?? item.href ?? `${index}-${item.label}`;\n\nfunction Separator({ kind, className = \"\" }: { kind: \"chevron\" | \"slash\" | \"dot\"; className?: string }) {\n  return (\n    <span aria-hidden=\"true\" className={`flex shrink-0 items-center text-zinc-300 dark:text-zinc-600 ${className}`}>\n      {kind === \"chevron\" ? (\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-[1em]\">\n          <path d=\"M6 3.5 10.5 8 6 12.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </svg>\n      ) : kind === \"slash\" ? (\n        <span className=\"font-light\">/</span>\n      ) : (\n        <span className=\"font-bold\">·</span>\n      )}\n    </span>\n  );\n}\n\n/**\n * Breadcrumbs that measure themselves with a ResizeObserver and fold middle items into an\n * accessible \"…\" menu button when space runs short, keeping the first and last items visible.\n * Collapsing and expanding animate with a layout spring; the current page carries aria-current.\n */\nexport function SmartBreadcrumbs({\n  items,\n  keepFirst = 1,\n  keepLast = 2,\n  separator = \"chevron\",\n  size = \"md\",\n  spring = { stiffness: 420, damping: 36, mass: 0.8 },\n  accent = \"#FF7448\",\n  label = \"Breadcrumb\",\n  overflowLabel = \"Show hidden pages\",\n  onNavigate,\n  className = \"\",\n  style,\n  ...props\n}: SmartBreadcrumbsProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const navRef = React.useRef<HTMLElement>(null);\n  const measureRef = React.useRef<HTMLOListElement>(null);\n  const buttonRef = React.useRef<HTMLButtonElement>(null);\n  const menuRef = React.useRef<HTMLUListElement>(null);\n  const focusOnOpen = React.useRef<\"first\" | \"last\">(\"first\");\n  const [layout, setLayout] = React.useState({ hidden: 0, ready: false, tight: false });\n  const [menuOpen, setMenuOpen] = React.useState(false);\n\n  const first = Math.max(0, Math.min(keepFirst, items.length));\n  const last = Math.max(1, Math.min(keepLast, items.length - first));\n  const collapsible = Math.max(0, items.length - first - last);\n\n  /** Finds how many middle items must fold away for the trail to fit on one line. */\n  const measure = React.useEffectEvent(() => {\n    const nav = navRef.current;\n    const ruler = measureRef.current;\n    if (!nav || !ruler) return;\n    const available = nav.clientWidth - 1;\n    const widthOf = (name: string) => (ruler.querySelector<HTMLElement>(`[data-measure=\"${name}\"]`)?.offsetWidth ?? 0);\n    const sep = widthOf(\"sep\");\n    const ellipsis = widthOf(\"ellipsis\");\n    const widths = items.map((_, i) => widthOf(String(i)));\n    const total = (hide: number) => {\n      let sum = 0;\n      for (let i = 0; i < widths.length; i++) if (i < first || i >= first + hide) sum += widths[i];\n      const lis = widths.length - hide + (hide > 0 ? 1 : 0);\n      return sum + (hide > 0 ? ellipsis : 0) + sep * Math.max(0, lis - 1);\n    };\n    let hidden = 0;\n    while (hidden < collapsible && total(hidden) > available) hidden++;\n    // A single folded item would trade one crumb for \"…\": fold at least two when possible.\n    if (hidden === 1 && collapsible >= 2) hidden = 2;\n    // Still too wide with everything folded: ancestors shrink to their icon (or a short label).\n    const tight = total(hidden) > available;\n    setLayout((prev) => (prev.hidden === hidden && prev.tight === tight && prev.ready ? prev : { hidden, tight, ready: true }));\n  });\n\n  React.useEffect(() => {\n    const nav = navRef.current;\n    const ruler = measureRef.current;\n    if (!nav || !ruler) return;\n    const observer = new ResizeObserver(() => measure());\n    observer.observe(nav);\n    observer.observe(ruler);\n    return () => observer.disconnect();\n  }, []);\n  // Re-measure when the trail or its rules change (the ruler's size may stay the same).\n  const signature = `${items.map((item) => item.label).join(\"\\u0000\")}|${first}|${last}|${separator}|${size}`;\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => measure());\n    return () => cancelAnimationFrame(frame);\n  }, [signature]);\n\n  const hidden = Math.min(layout.hidden, collapsible);\n  const hiddenItems = items.slice(first, first + hidden);\n  // Close the menu when nothing is folded any more (adjust state during render).\n  if (menuOpen && hidden === 0) setMenuOpen(false);\n\n  // While the menu is open: focus an item, close on outside pointer or focus leaving.\n  React.useEffect(() => {\n    if (!menuOpen) return;\n    const menuItems = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role=\"menuitem\"]') ?? []);\n    (focusOnOpen.current === \"last\" ? menuItems[menuItems.length - 1] : menuItems[0])?.focus();\n    const onPointerDown = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current?.contains(target) || buttonRef.current?.contains(target)) return;\n      setMenuOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [menuOpen]);\n\n  const openMenu = (focus: \"first\" | \"last\") => {\n    focusOnOpen.current = focus;\n    setMenuOpen(true);\n  };\n  const closeMenu = (returnFocus: boolean) => {\n    setMenuOpen(false);\n    if (returnFocus) buttonRef.current?.focus();\n  };\n\n  const onMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    const menuItems = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[role=\"menuitem\"]'));\n    const index = menuItems.indexOf(document.activeElement as HTMLElement);\n    const go = (i: number) => menuItems[(i + menuItems.length) % menuItems.length]?.focus();\n    if (event.key === \"ArrowDown\") go(index + 1);\n    else if (event.key === \"ArrowUp\") go(index - 1);\n    else if (event.key === \"Home\") go(0);\n    else if (event.key === \"End\") go(menuItems.length - 1);\n    else if (event.key === \"Escape\") closeMenu(true);\n    else if (event.key === \"Tab\") {\n      setMenuOpen(false);\n      return;\n    } else return;\n    event.preventDefault();\n  };\n\n  const text = size === \"sm\" ? \"text-[13px]\" : \"text-[15px]\";\n  const sepPad = size === \"sm\" ? \"px-1.5\" : \"px-2\";\n  const iconBox = size === \"sm\" ? \"[&_svg]:size-3.5\" : \"[&_svg]:size-4\";\n  const crumbBase = `inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap ${iconBox}`;\n  const linkClass = `${crumbBase} rounded-[3px] text-zinc-500 underline-offset-4 decoration-[var(--sb-accent)] transition-colors outline-hidden hover:text-zinc-900 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] dark:text-zinc-400 dark:hover:text-zinc-100`;\n  const transition = layout.ready && !reduceMotion ? { type: \"spring\" as const, ...spring } : { duration: 0 };\n\n  const crumbContent = (item: BreadcrumbItem, current: boolean, tight = false) => (\n    <>\n      {item.icon ? (\n        <span aria-hidden=\"true\" className=\"flex shrink-0 items-center\">\n          {item.icon}\n        </span>\n      ) : null}\n      <span className={current ? \"truncate\" : tight ? (item.icon ? \"sr-only\" : \"max-w-[4rem] truncate\") : \"max-w-[12rem] truncate\"}>{item.label}</span>\n    </>\n  );\n\n  const renderCrumb = (item: BreadcrumbItem, index: number) => {\n    const current = index === items.length - 1;\n    if (current) {\n      return (\n        <span aria-current=\"page\" className={`${crumbBase} font-medium text-zinc-900 dark:text-zinc-50`}>\n          {crumbContent(item, true)}\n        </span>\n      );\n    }\n    if (item.href) {\n      return (\n        <a href={item.href} title={layout.tight ? item.label : undefined} className={linkClass} onClick={(event) => onNavigate?.(item, event)}>\n          {crumbContent(item, false, layout.tight)}\n        </a>\n      );\n    }\n    return (\n      <span title={layout.tight ? item.label : undefined} className={`${crumbBase} text-zinc-500 dark:text-zinc-400`}>\n        {crumbContent(item, false, layout.tight)}\n      </span>\n    );\n  };\n\n  // Visible list: first items, the overflow crumb, then the rest. Only the current page truncates.\n  type Entry = { key: string; node: React.ReactNode; current?: boolean };\n  const entries: Entry[] = [];\n  items.forEach((item, index) => {\n    if (index === first && hidden > 0) entries.push({ key: \"__overflow\", node: null });\n    if (index >= first && index < first + hidden) return;\n    entries.push({ key: keyOf(item, index), node: renderCrumb(item, index), current: index === items.length - 1 });\n  });\n\n  const menuId = `${uid}-menu`;\n  const buttonId = `${uid}-more`;\n\n  return (\n    <nav\n      ref={navRef}\n      aria-label={label}\n      className={`relative w-full min-w-0 ${text} ${className}`}\n      style={{ [\"--sb-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <ol className=\"relative flex min-w-0 flex-nowrap items-center\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {entries.map((entry, i) => (\n            <motion.li\n              key={entry.key}\n              layout=\"position\"\n              initial={{ opacity: 0, x: -6 }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0, x: -6, transition: { duration: layout.ready && !reduceMotion ? 0.14 : 0 } }}\n              transition={transition}\n              className={`flex items-center ${entry.key === \"__overflow\" ? \"relative shrink-0\" : entry.current ? \"min-w-0\" : \"shrink-0\"}`}\n            >\n              {i > 0 ? <Separator kind={separator} className={sepPad} /> : null}\n              {entry.key === \"__overflow\" ? (\n                <>\n                  <button\n                    ref={buttonRef}\n                    id={buttonId}\n                    type=\"button\"\n                    aria-label={`${overflowLabel} (${hidden})`}\n                    aria-haspopup=\"menu\"\n                    aria-expanded={menuOpen}\n                    aria-controls={menuOpen ? menuId : undefined}\n                    onClick={() => (menuOpen ? closeMenu(false) : openMenu(\"first\"))}\n                    onKeyDown={(event) => {\n                      if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n                        event.preventDefault();\n                        openMenu(event.key === \"ArrowUp\" ? \"last\" : \"first\");\n                      }\n                    }}\n                    className={`grid h-6 min-w-7 place-items-center rounded-md px-1.5 tracking-widest text-zinc-500 transition-colors outline-hidden hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 ${menuOpen ? \"bg-zinc-900/[0.06] text-zinc-900 dark:bg-white/[0.08] dark:text-zinc-100\" : \"\"}`}\n                  >\n                    <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-4\">\n                      <circle cx=\"3.5\" cy=\"8\" r=\"1.25\" fill=\"currentColor\" />\n                      <circle cx=\"8\" cy=\"8\" r=\"1.25\" fill=\"currentColor\" />\n                      <circle cx=\"12.5\" cy=\"8\" r=\"1.25\" fill=\"currentColor\" />\n                    </svg>\n                  </button>\n                  <AnimatePresence>\n                    {menuOpen ? (\n                      <motion.ul\n                        ref={menuRef}\n                        id={menuId}\n                        role=\"menu\"\n                        aria-labelledby={buttonId}\n                        onKeyDown={onMenuKeyDown}\n                        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -4 }}\n                        animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}\n                        exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -2, transition: { duration: 0.12 } }}\n                        transition={reduceMotion ? { duration: 0.01 } : { type: \"spring\", stiffness: 520, damping: 34 }}\n                        style={{ transformOrigin: \"16px 0px\" }}\n                        className={`absolute left-0 top-full z-30 mt-2 min-w-48 max-w-72 rounded-xl border border-zinc-900/10 bg-white p-1 shadow-[0_18px_40px_-18px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-[#1a1d21] ${size === \"sm\" ? \"-ml-1.5\" : \"-ml-2\"}`}\n                      >\n                        {hiddenItems.map((item, i) => {\n                          const itemClass = `flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-zinc-700 outline-hidden ${iconBox} hover:bg-zinc-900/[0.05] focus:bg-zinc-900/[0.06] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] dark:text-zinc-300 dark:hover:bg-white/[0.06] dark:focus:bg-white/[0.08]`;\n                          const inner = (\n                            <>\n                              <span aria-hidden=\"true\" className=\"w-4 shrink-0 font-mono text-[10px] tabular-nums text-zinc-400 dark:text-zinc-500\">\n                                {first + i + 1}\n                              </span>\n                              {item.icon ? (\n                                <span aria-hidden=\"true\" className=\"flex shrink-0 items-center text-zinc-400 dark:text-zinc-500\">\n                                  {item.icon}\n                                </span>\n                              ) : null}\n                              <span className=\"truncate\">{item.label}</span>\n                            </>\n                          );\n                          return (\n                            <li key={keyOf(item, first + i)} role=\"none\">\n                              {item.href ? (\n                                <a\n                                  role=\"menuitem\"\n                                  tabIndex={-1}\n                                  href={item.href}\n                                  className={itemClass}\n                                  onClick={(event) => {\n                                    onNavigate?.(item, event);\n                                    setMenuOpen(false);\n                                  }}\n                                >\n                                  {inner}\n                                </a>\n                              ) : (\n                                <button\n                                  role=\"menuitem\"\n                                  tabIndex={-1}\n                                  type=\"button\"\n                                  className={itemClass}\n                                  onClick={(event) => {\n                                    onNavigate?.(item, event);\n                                    closeMenu(true);\n                                  }}\n                                >\n                                  {inner}\n                                </button>\n                              )}\n                            </li>\n                          );\n                        })}\n                      </motion.ul>\n                    ) : null}\n                  </AnimatePresence>\n                </>\n              ) : (\n                entry.node\n              )}\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ol>\n\n      {/* Invisible ruler: every crumb at its natural width, plus a separator and the overflow button. */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden\">\n      <ol ref={measureRef} className=\"flex w-max items-center whitespace-nowrap\">\n        <li data-measure=\"sep\" className=\"flex\">\n          <Separator kind={separator} className={sepPad} />\n        </li>\n        <li data-measure=\"ellipsis\" className=\"flex\">\n          <span className=\"grid h-6 min-w-7 px-1.5\" />\n        </li>\n        {items.map((item, index) => (\n          <li key={keyOf(item, index)} data-measure={index} className=\"flex\">\n            <span className={`${crumbBase} ${index === items.length - 1 ? \"font-medium\" : \"\"}`}>{crumbContent(item, index === items.length - 1)}</span>\n          </li>\n        ))}\n      </ol>\n      </div>\n    </nav>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/smart-breadcrumbs"
}
