{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mobile-bottom-nav",
  "type": "registry:ui",
  "title": "MobileBottomNav",
  "description": "Data-driven bottom nav that collapses its items into a hamburger menu when they and the toolbar would overflow the viewport.",
  "categories": [
    "navigation"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/use-focus-trap.json",
    "https://whiskeyjack.net/r/use-nav-reset.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/mobile-bottom-nav.tsx",
      "type": "registry:ui",
      "target": "components/ui/mobile-bottom-nav.tsx",
      "content": "import {\n  forwardRef,\n  useEffect,\n  useRef,\n  useState,\n  type ElementType,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useFocusTrap } from \"@/hooks/use-focus-trap\";\nimport { useNavDoubleActivate } from \"@/hooks/use-nav-reset\";\n\nexport interface MobileNavItem {\n  /** Stable key for React lists. */\n  key: string;\n  label: string;\n  active?: boolean;\n  /** Router target -- rendered via `linkComponent` (or a plain `<a href>`). */\n  to?: string;\n  /**\n   * Click handler. Rendered as a `<button>` when there is no `to`; also fired\n   * on selection of a `to` item (e.g. to run side effects alongside nav).\n   */\n  onClick?: () => void;\n  /**\n   * Renders the item's icon at the given px size; `active` lets the caller\n   * switch weight/fill. Called for both the bottom bar (size 24, stacked) and\n   * the overflow menu (size 20, inline), so the DS stays icon-library-agnostic.\n   */\n  renderIcon: (opts: { active: boolean; size: number }) => ReactNode;\n}\n\nexport interface MobileBottomNavProps {\n  navItems: MobileNavItem[];\n  /**\n   * Toolbar pill (e.g. an `ActionPill`) nested to the right of the nav items.\n   * Optional -- omit it for a nav that has no toolbar actions.\n   */\n  toolbar?: ReactNode;\n  /**\n   * Worst-case copy of the toolbar -- every action visible -- used ONLY for the\n   * overflow measurement. Toolbar contents can change per page (1 action on one\n   * page, 3 on another); measuring the widest case keeps the collapse decision\n   * stable so the nav doesn't flip between inline and hamburger as you navigate.\n   * Falls back to `toolbar` when omitted.\n   */\n  measureToolbar?: ReactNode;\n  /** Hamburger icon for the collapsed state (e.g. a Phosphor `<List size={24} />`). */\n  menuIcon: ReactNode;\n  /** Label under the collapsed hamburger + its aria-label (caller-translated). */\n  menuLabel?: string;\n  /**\n   * Router `Link` component for `to` items (e.g. react-router's `Link`), so the\n   * DS needn't depend on a router. Falls back to a plain `<a href>`.\n   */\n  linkComponent?: ElementType;\n  /**\n   * The app's scroll region, for the double-tap-to-top gesture on the active\n   * item. Pass the same ref the Layout gives `AppMain`; omit it for a\n   * document-scroll app. A ref rather than context because this nav is Layout\n   * chrome rendered ALONGSIDE `AppMain`, so it sits above the scroll region\n   * `AppMain` publishes and cannot read it -- the same reason `useScrollReset`\n   * takes one. Without it a shell-scroll app would silently scroll the window,\n   * which has no overflow to move.\n   */\n  scrollRef?: RefObject<HTMLElement | null>;\n  className?: string;\n  /** Inline styles for the root element. */\n  style?: React.CSSProperties;\n}\n\n// Frosted pill chrome -- mirrors the `BottomNav` inner pill.\nconst PILL =\n  \"flex items-center gap-1 h-16 px-2 rounded-full wj-frost-chrome shadow-lg \" +\n  \"border border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]\";\n\n// Bottom-bar item: stacked icon over label (the `BottomNavItem` style).\n// `touch-manipulation` opts the item out of double-tap-to-zoom, which Safari\n// would otherwise claim on a zoomable page -- the active item's double-tap\n// gesture (see useNavDoubleActivate) needs the taps. Pinch-zoom is untouched,\n// so page zoom stays available.\nconst BAR_ITEM =\n  \"wj-focus-ring flex flex-col items-center justify-center px-4 py-2 rounded-xl transition-colors touch-target touch-manipulation\";\n\n// Menu row: inline icon beside label (matches an app's desktop header nav links).\nconst MENU_ITEM =\n  \"wj-focus-ring flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full touch-manipulation\";\n\nfunction itemColor(active: boolean): string {\n  return active\n    ? \"text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]\"\n    : \"text-[var(--color-text-muted-light)] dark:text-[var(--color-text-muted-dark)]\";\n}\n\nfunction menuItemColor(active: boolean): string {\n  // The shared nav highlight, the same fill an unselected option tile carries.\n  return active\n    ? \"bg-[var(--wj-nav-active)] text-[var(--color-text-primary-light)] shadow-bevel dark:text-[var(--color-text-primary-dark)]\"\n    : \"text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)] hover:bg-[var(--wj-nav-active)]\";\n}\n\n/**\n * Mobile bottom navigation that consolidates its nav items into a hamburger\n * menu when they would overflow the viewport width.\n *\n * Layout: a single frosted pill holding the nav items with the `toolbar` pill\n * nested to their right. When the worst-case content (all nav items + every\n * toolbar action) would exceed the available width -- measured via a hidden,\n * always-expanded copy + a `ResizeObserver` -- the nav items collapse to a\n * single hamburger button; tapping it opens a frosted menu above the bar that\n * lists the items inline. Measuring the worst case (not the current page's\n * toolbar) keeps the decision stable: it's the hamburger on every page or\n * inline on every page, never flipping as you navigate. Mobile-only\n * (`md:hidden`); pair with a desktop header nav.\n */\nexport const MobileBottomNav = forwardRef<HTMLDivElement, MobileBottomNavProps>(function MobileBottomNav({\n  navItems,\n  toolbar,\n  measureToolbar,\n  menuIcon,\n  menuLabel = \"Menu\",\n  linkComponent: LinkComponent,\n  scrollRef,\n  className,\n  style,\n}, ref) {\n  const onActivate = useNavDoubleActivate(scrollRef);\n  const [overflow, setOverflow] = useState(false);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLDivElement>(null);\n  // Menu container -- focus trap target.\n  const menuRef = useRef<HTMLDivElement>(null);\n\n  // Trap focus inside the overflow menu while it's open; restores hamburger focus on close.\n  useFocusTrap(menuRef, overflow && menuOpen);\n\n  // Escape closes the menu (focus trap handles Tab containment).\n  useEffect(() => {\n    if (!menuOpen) return;\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setMenuOpen(false);\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [menuOpen]);\n\n  // Collapse when the worst-case content is wider than the available row. The\n  // hidden measure copy is always expanded (all nav items + the full toolbar),\n  // so the decision never flip-flops with the collapsed state OR with the\n  // current page's toolbar. Re-runs on container OR content resize (the latter\n  // covers label/locale width changes).\n  useEffect(() => {\n    const container = containerRef.current;\n    const measure = measureRef.current;\n    if (!container || !measure) return;\n    const check = () => setOverflow(measure.scrollWidth > container.clientWidth + 1);\n    check();\n    const ro = new ResizeObserver(check);\n    ro.observe(container);\n    ro.observe(measure);\n    return () => ro.disconnect();\n  }, []);\n\n  // Close the menu if we grow back to fitting inline.\n  useEffect(() => {\n    if (!overflow) setMenuOpen(false);\n  }, [overflow]);\n\n  const renderNavEl = (\n    item: MobileNavItem,\n    classes: string,\n    onClick: (() => void) | undefined,\n    children: ReactNode,\n    role?: string,\n  ) => {\n    const props = {\n      className: classes,\n      // Wrapped here rather than at each call site so the bar and the overflow\n      // menu share one activation path.\n      onClick: () => {\n        onActivate(!!item.active);\n        onClick?.();\n      },\n      ...(role ? { role } : {}),\n      // Expose the active page to assistive tech, not just visually.\n      ...(item.active && item.to ? { \"aria-current\": \"page\" as const } : {}),\n    };\n    if (item.to) {\n      if (LinkComponent) {\n        return (\n          <LinkComponent key={item.key} to={item.to} {...props}>\n            {children}\n          </LinkComponent>\n        );\n      }\n      return (\n        <a key={item.key} href={item.to} {...props}>\n          {children}\n        </a>\n      );\n    }\n    return (\n      <button key={item.key} type=\"button\" {...props}>\n        {children}\n      </button>\n    );\n  };\n\n  const renderBarItem = (item: MobileNavItem) =>\n    renderNavEl(\n      item,\n      cn(BAR_ITEM, itemColor(!!item.active)),\n      item.onClick,\n      <>\n        {item.renderIcon({ active: !!item.active, size: 24 })}\n        <span className=\"text-xs mt-1 font-medium\">{item.label}</span>\n      </>,\n    );\n\n  return (\n    <div ref={ref} className={cn(\"md:hidden\", className)} style={style}>\n      <nav className=\"fixed bottom-0 left-2 right-2 z-50 bottom-nav-safe\">\n        {/* Hidden, always-expanded copy of the whole pill (items + worst-case\n            toolbar) at its natural width, used only to decide whether it fits. */}\n        <div ref={measureRef} aria-hidden className=\"pointer-events-none invisible absolute left-0 top-0\">\n          <div className={cn(PILL, \"w-fit whitespace-nowrap\")}>\n            {navItems.map(renderBarItem)}\n            {measureToolbar ?? toolbar}\n          </div>\n        </div>\n\n        {/* Available-width reference (capped + centered). The single frosted pill\n            sizes to its content within it -- the toolbar stays nested in the same\n            pill, with the nav items (or the hamburger) to its left. */}\n        <div ref={containerRef} className=\"max-w-lg mx-auto\">\n          <div className={cn(PILL, \"w-fit max-w-full mx-auto\")}>\n            {overflow ? (\n              <button\n                type=\"button\"\n                aria-label={menuLabel}\n                aria-expanded={menuOpen}\n                aria-haspopup=\"menu\"\n                onClick={() => setMenuOpen((o) => !o)}\n                className={cn(BAR_ITEM, itemColor(navItems.some((i) => i.active)))}\n              >\n                {menuIcon}\n                <span className=\"text-xs mt-1 font-medium\">{menuLabel}</span>\n              </button>\n            ) : (\n              navItems.map(renderBarItem)\n            )}\n            {toolbar}\n          </div>\n        </div>\n      </nav>\n\n      {/* Frosted menu above the bar; items as inline rows. */}\n      {overflow && menuOpen && (\n        <>\n          <div className=\"fixed inset-0 z-40\" onClick={() => setMenuOpen(false)} aria-hidden />\n          <div\n            ref={menuRef}\n            tabIndex={-1}\n            className={cn(\n              \"fixed start-2 z-50 min-w-[12rem] max-w-[16rem] p-2 rounded-2xl flex flex-col gap-1\",\n              \"wj-enter wj-frost shadow-lg\",\n              \"border border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]\",\n            )}\n            style={{ bottom: \"calc(var(--wj-safe-bottom) + 4.75rem)\" }}\n            role=\"menu\"\n          >\n            {navItems.map((item) =>\n              renderNavEl(\n                item,\n                cn(MENU_ITEM, menuItemColor(!!item.active)),\n                () => {\n                  setMenuOpen(false);\n                  item.onClick?.();\n                },\n                <>\n                  {item.renderIcon({ active: !!item.active, size: 20 })}\n                  <span>{item.label}</span>\n                </>,\n                \"menuitem\",\n              ),\n            )}\n          </div>\n        </>\n      )}\n    </div>\n  );\n});\n\nMobileBottomNav.displayName = \"MobileBottomNav\";\n"
    }
  ],
  "docs": "Give it navItems plus a toolbar. It measures the worst case (every toolbar action visible) so the collapse decision stays stable across pages. Router- and icon-agnostic: pass linkComponent for `to` items and a per-item renderIcon. Mobile-only (md:hidden), so pair it with a desktop HeaderNav.",
  "meta": {
    "group": "navigation",
    "related": [
      "bottom-nav",
      "action-pill",
      "app-shell"
    ],
    "exports": [
      "MobileBottomNav"
    ],
    "siteSlug": "mobile-bottom-nav"
  }
}
