{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "overflow-menu",
  "type": "registry:ui",
  "title": "Overflow Menu",
  "description": "Folds the least important controls into More as it narrows, by priority.",
  "categories": [
    "menus"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/tooltip.json"
  ],
  "files": [
    {
      "path": "components/ui/overflow-menu.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Toolbar } from \"@base-ui/react/toolbar\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, MoreH } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { Tooltip, TooltipProvider } from \"@/components/ui/tooltip\";\n\nexport type OverflowItem = {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  onSelect?: () => void;\n  /** Higher stays in the bar longer. Ties fall back to order: later items fold first. */\n  priority?: number;\n  /** Makes it a toggle: aria-pressed in the bar, a checkbox item in the menu. */\n  pressed?: boolean;\n  /** Show only the icon in the bar (the label becomes its name and tooltip). */\n  iconOnly?: boolean;\n  disabled?: boolean;\n  tone?: \"default\" | \"danger\";\n};\n\n/**\n * Which items fit in `width`, by priority. The ones that don't fold into the menu, and when\n * anything folds the menu button's width is reserved first.\n */\nexport function fitItems(items: OverflowItem[], widths: Record<string, number>, width: number, moreWidth: number, gap: number) {\n  const total = items.reduce((sum, it, i) => sum + (widths[it.id] ?? 0) + (i ? gap : 0), 0);\n  if (total <= width) return new Set(items.map((i) => i.id));\n  const budget = width - moreWidth - gap;\n  const ranked = items.map((it, i) => ({ it, i })).sort((a, b) => (b.it.priority ?? 0) - (a.it.priority ?? 0) || a.i - b.i);\n  const fit = new Set<string>();\n  let used = 0;\n  for (const { it } of ranked) {\n    const w = (widths[it.id] ?? 0) + (fit.size ? gap : 0);\n    // Strict priority: once something doesn't fit, nothing ranked below it squeezes in ahead of it.\n    if (used + w > budget) break;\n    used += w;\n    fit.add(it.id);\n  }\n  return fit;\n}\n\nconst GAP = 2;\n\nexport type OverflowMenuProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  items: OverflowItem[];\n  /** Accessible name of the toolbar. */\n  label: string;\n  /** Label of the overflow button. */\n  moreLabel?: string;\n  /** Content after the collapsible items that never folds, e.g. a primary action. */\n  end?: React.ReactNode;\n  /** Called with the ids that are currently folded into the menu. */\n  onOverflowChange?: (hidden: string[]) => void;\n};\n\n/**\n * A toolbar that folds its lowest-priority items into a \"more\" menu as it narrows, and lets them\n * back out as it widens. Items leave the bar with a fade while their neighbors spring into place.\n */\nexport function OverflowMenu({ items, label, moreLabel = \"More\", end, onOverflowChange, className, ...rest }: OverflowMenuProps) {\n  const reduce = useReducedMotion();\n  const regionRef = useRef<HTMLDivElement>(null);\n  const ghostRef = useRef<HTMLDivElement>(null);\n  const moreGhostRef = useRef<HTMLSpanElement>(null);\n  const moreRef = useRef<HTMLButtonElement>(null);\n  // Everything shows until the first measurement, which lands before the first paint.\n  const [visible, setVisible] = useState<Set<string> | null>(null);\n  const [settled, setSettled] = useState(false);\n  const visibleRef = useRef<Set<string> | null>(null);\n\n  // One observer measures the region and every item's natural width from a hidden copy of the row.\n  const idsKey = items.map((i) => `${i.id}:${i.label}:${i.iconOnly ? 1 : 0}`).join(\"|\");\n  const itemsRef = useRef(items);\n  useLayoutEffect(() => {\n    itemsRef.current = items;\n  });\n  useLayoutEffect(() => {\n    const region = regionRef.current;\n    const ghost = ghostRef.current;\n    if (!region || !ghost) return;\n    const measure = () => {\n      const widths: Record<string, number> = {};\n      ghost.querySelectorAll<HTMLElement>(\"[data-ghost-id]\").forEach((el) => {\n        widths[el.dataset.ghostId!] = el.getBoundingClientRect().width;\n      });\n      const moreWidth = moreGhostRef.current?.getBoundingClientRect().width ?? 32;\n      const next = fitItems(itemsRef.current, widths, region.clientWidth, moreWidth, GAP);\n      const prev = visibleRef.current;\n      if (prev && prev.size === next.size && [...next].every((id) => prev.has(id))) return;\n      // Anything folding away while focused hands focus to the menu button instead of the page.\n      const focused = document.activeElement?.closest<HTMLElement>(\"[data-item-id]\")?.dataset.itemId;\n      if (focused && !next.has(focused)) requestAnimationFrame(() => moreRef.current?.focus());\n      visibleRef.current = next;\n      setVisible(next);\n      // The first fit happens before paint and shouldn't animate; later ones should.\n      if (!prev) requestAnimationFrame(() => setSettled(true));\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(region);\n    ro.observe(ghost);\n    return () => ro.disconnect();\n  }, [idsKey]);\n\n  const shown = visible ? items.filter((i) => visible.has(i.id)) : items;\n  const hidden = visible ? items.filter((i) => !visible.has(i.id)) : [];\n  const hiddenKey = hidden.map((i) => i.id).join(\",\");\n  useEffect(() => {\n    onOverflowChange?.(hiddenKey ? hiddenKey.split(\",\") : []);\n  }, [hiddenKey, onOverflowChange]);\n\n  const layout = reduce || !settled ? false : (\"position\" as const);\n  const still = { opacity: 0, transition: { duration: 0 } };\n\n  return (\n    <TooltipProvider>\n      <Toolbar.Root aria-label={label} className={cn(\"relative flex min-w-0 items-center gap-2\", className)} {...rest}>\n        <div ref={regionRef} className=\"relative flex min-w-0 flex-1 items-center overflow-hidden\" style={{ gap: GAP }}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {shown.map((it) => (\n              <motion.div\n                key={it.id}\n                layout={layout}\n                data-item-id={it.id}\n                className=\"shrink-0\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                exit={!settled ? still : reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                transition={reduce ? { duration: 0.12 } : { ...spring.snappy, opacity: { duration: 0.16, ease: ease.out } }}\n              >\n                <BarItem item={it} />\n              </motion.div>\n            ))}\n            {hidden.length > 0 && (\n              <motion.div\n                key=\"__more\"\n                layout={layout}\n                className=\"shrink-0\"\n                initial={!settled ? false : reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, transition: { duration: 0.12 } }}\n                transition={reduce ? { duration: 0.12 } : spring.pop}\n              >\n                <Menu.Root>\n                  <Toolbar.Button\n                    ref={moreRef}\n                    render={<Menu.Trigger />}\n                    aria-label={`${moreLabel}, ${hidden.length} ${hidden.length === 1 ? \"item\" : \"items\"}`}\n                    className={cn(buttonClass, \"gap-1 pl-2 pr-2 data-popup-open:bg-fg/[0.08] data-popup-open:text-fg\")}\n                  >\n                    <MoreH />\n                    {/* The count ticks as items fold in, so it's clear where they went. */}\n                    <span aria-hidden className=\"tabular text-[11.5px] text-fg-3\">\n                      <NumberFlow value={hidden.length} animated={!reduce} />\n                    </span>\n                  </Toolbar.Button>\n                  <Menu.Portal>\n                    <Menu.Positioner side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} className=\"z-(--z-dropdown)\">\n                      <Menu.Popup className={popupClass}>\n                        {hidden.map((it) => (\n                          <MenuItem key={it.id} item={it} />\n                        ))}\n                      </Menu.Popup>\n                    </Menu.Positioner>\n                  </Menu.Portal>\n                </Menu.Root>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n        {end}\n\n        {/* A hidden copy of every item at its natural width: what the fitting is measured against. */}\n        <div ref={ghostRef} aria-hidden inert className=\"pointer-events-none invisible absolute left-0 top-0 flex w-max\" style={{ gap: GAP }}>\n          {items.map((it) => (\n            <span key={it.id} data-ghost-id={it.id} className=\"shrink-0\">\n              <span className={cn(buttonClass, it.iconOnly ? \"w-8 px-0\" : \"px-2.5\")}>\n                {it.icon}\n                {!it.iconOnly && it.label}\n              </span>\n            </span>\n          ))}\n          <span ref={moreGhostRef} className={cn(buttonClass, \"gap-1 pl-2 pr-2\")}>\n            <MoreH />\n            <span className=\"tabular text-[11.5px]\">{items.length}</span>\n          </span>\n        </div>\n      </Toolbar.Root>\n    </TooltipProvider>\n  );\n}\n\nconst buttonClass = cn(\n  \"relative inline-flex h-8 shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-lg text-[12.5px] font-medium text-fg-2 outline-none\",\n  \"transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.06] hover:text-fg active:scale-[0.96] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n  \"aria-pressed:bg-fg/[0.1] aria-pressed:text-fg data-disabled:pointer-events-none data-disabled:opacity-40\",\n  \"[&_svg]:size-4 [&_svg]:shrink-0\",\n);\n\nconst popupClass = cn(\n  \"min-w-48 max-w-(--available-width) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n  \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-160 ease-out-expo\",\n  \"data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0\",\n  \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100\",\n  \"data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n);\n\nfunction BarItem({ item }: { item: OverflowItem }) {\n  const button = (\n    <Toolbar.Button\n      aria-label={item.iconOnly ? item.label : undefined}\n      aria-pressed={item.pressed}\n      disabled={item.disabled}\n      onClick={item.onSelect}\n      className={cn(buttonClass, item.iconOnly ? \"w-8 px-0\" : \"px-2.5\", item.tone === \"danger\" && \"hover:bg-danger-soft hover:text-danger\")}\n    >\n      {item.icon}\n      {!item.iconOnly && item.label}\n    </Toolbar.Button>\n  );\n  return item.iconOnly ? <Tooltip content={item.label}>{button}</Tooltip> : button;\n}\n\nfunction MenuItem({ item }: { item: OverflowItem }) {\n  const cls = cn(\n    \"flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none pointer-coarse:h-10\",\n    \"transition-colors duration-75 data-highlighted:bg-fg/[0.06] data-disabled:opacity-40\",\n    \"[&_svg]:size-4 [&_svg]:shrink-0\",\n    item.tone === \"danger\" ? \"text-danger data-highlighted:bg-danger-soft\" : \"[&>svg:first-child]:text-fg-3\",\n  );\n  if (item.pressed !== undefined) {\n    return (\n      <Menu.CheckboxItem checked={item.pressed} onCheckedChange={() => item.onSelect?.()} disabled={item.disabled} className={cls}>\n        {item.icon}\n        <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n        <Menu.CheckboxItemIndicator keepMounted className=\"grid size-4 place-items-center text-fg transition-[opacity,scale] duration-150 data-unchecked:scale-75 data-unchecked:opacity-0\">\n          <Check />\n        </Menu.CheckboxItemIndicator>\n      </Menu.CheckboxItem>\n    );\n  }\n  return (\n    <Menu.Item onClick={item.onSelect} disabled={item.disabled} className={cls}>\n      {item.icon}\n      <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n    </Menu.Item>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/overflow-menu"
}
