{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bloom-menu","type":"registry:ui","title":"Bloom Menu","description":"A button that blooms into its own menu: the pill stretches into a panel in place, the label fades into the actions, and it folds back when you pick one. Arrow keys, Home/End and Escape work.","dependencies":["motion","react-use-measure"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/bloom-menu.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport useMeasure from \"react-use-measure\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface BloomMenuItem {\n  label: string;\n  icon?: ReactNode;\n  /** Short hint shown on the right, e.g. a keyboard shortcut. */\n  hint?: string;\n  /** Red styling for destructive actions. */\n  destructive?: boolean;\n  onSelect?: () => void;\n}\n\nexport interface BloomMenuProps {\n  /** Trigger button text. */\n  label: string;\n  /** Optional icon before the label. */\n  icon?: ReactNode;\n  items: BloomMenuItem[];\n  /** Which edge the menu grows from. */\n  align?: \"left\" | \"right\";\n  /** Spring bounce of the morph, 0 to 0.5. */\n  bounce?: number;\n  /** Morph length in seconds. */\n  duration?: number;\n  className?: string;\n}\n\nconst TRIGGER = \"inline-flex h-10 items-center gap-2 whitespace-nowrap px-4 text-sm font-medium\";\n\n/**\n * A button that blooms into its own menu: the pill stretches into a panel in\n * place, its label fades into the list of actions, and it folds back into the\n * button when an item is picked or the menu is dismissed.\n */\nexport function BloomMenu({\n  label,\n  icon,\n  items,\n  align = \"left\",\n  bounce = 0.2,\n  duration = 0.42,\n  className,\n}: BloomMenuProps) {\n  const [open, setOpen] = useState(false);\n  const [measure, { width, height }] = useMeasure();\n  const root = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const menu = useRef<HTMLDivElement>(null);\n  const refocus = useRef(false);\n\n  const close = (returnFocus: boolean) => {\n    refocus.current = returnFocus;\n    setOpen(false);\n  };\n\n  // Focus the first item on open; give focus back to the button on keyboard close.\n  useEffect(() => {\n    if (open) menu.current?.querySelector<HTMLElement>(\"[role=menuitem]\")?.focus();\n    else if (refocus.current) {\n      refocus.current = false;\n      trigger.current?.focus();\n    }\n  }, [open]);\n\n  // Click outside closes without stealing focus.\n  useEffect(() => {\n    if (!open) return;\n    const down = (e: PointerEvent) => {\n      if (!root.current?.contains(e.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [open]);\n\n  const onMenuKey = (e: KeyboardEvent) => {\n    const els = [...(menu.current?.querySelectorAll<HTMLElement>(\"[role=menuitem]\") ?? [])];\n    const i = els.indexOf(document.activeElement as HTMLElement);\n    const go = (n: number) => els[(n + els.length) % els.length]?.focus();\n    if (e.key === \"ArrowDown\") go(i + 1);\n    else if (e.key === \"ArrowUp\") go(i - 1);\n    else if (e.key === \"Home\") go(0);\n    else if (e.key === \"End\") go(els.length - 1);\n    else if (e.key === \"Escape\") close(true);\n    else if (e.key === \"Tab\") close(false);\n    else return;\n    if (e.key !== \"Tab\") e.preventDefault();\n  };\n\n  const spring = { type: \"spring\" as const, bounce, duration };\n  const fade = { duration: duration * 0.45, ease: \"easeOut\" as const };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div ref={root} className={cn(\"relative inline-block align-top\", className)}>\n        {/* Reserves the button's footprint so the open menu floats over the page. */}\n        <span aria-hidden className={cn(TRIGGER, \"invisible\")}>\n          {icon}\n          {label}\n        </span>\n        <motion.div\n          className={cn(\n            \"absolute top-0 z-50 overflow-hidden border border-border bg-card text-card-foreground\",\n            align === \"right\" ? \"right-0\" : \"left-0\",\n            open ? \"shadow-2xl\" : \"shadow-sm\",\n          )}\n          initial={false}\n          animate={{ width: width || \"auto\", height: height || \"auto\", borderRadius: open ? 16 : 20 }}\n          transition={spring}\n        >\n          <div ref={measure} className=\"relative w-max\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {open ? (\n                <motion.div\n                  key=\"menu\"\n                  ref={menu}\n                  role=\"menu\"\n                  aria-label={label}\n                  onKeyDown={onMenuKey}\n                  className=\"w-56 p-1.5\"\n                  initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\", transition: { ...fade, delay: duration * 0.15 } }}\n                  exit={{ opacity: 0, filter: \"blur(4px)\", transition: fade }}\n                >\n                  {items.map((item, i) => (\n                    <motion.button\n                      key={item.label}\n                      type=\"button\"\n                      role=\"menuitem\"\n                      tabIndex={-1}\n                      onClick={() => {\n                        item.onSelect?.();\n                        close(true);\n                      }}\n                      className={cn(\n                        \"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm outline-none transition-colors\",\n                        item.destructive\n                          ? \"text-destructive hover:bg-destructive/10 focus-visible:bg-destructive/10\"\n                          : \"text-foreground hover:bg-muted focus-visible:bg-muted\",\n                      )}\n                      initial={{ opacity: 0, y: -4 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      transition={{ ...fade, delay: duration * 0.2 + i * 0.03 }}\n                    >\n                      {item.icon && <span className=\"grid size-4 place-items-center [&>svg]:size-4\">{item.icon}</span>}\n                      <span className=\"flex-1\">{item.label}</span>\n                      {item.hint && <span className=\"text-xs text-muted-foreground\">{item.hint}</span>}\n                    </motion.button>\n                  ))}\n                </motion.div>\n              ) : (\n                <motion.button\n                  key=\"trigger\"\n                  ref={trigger}\n                  type=\"button\"\n                  aria-haspopup=\"menu\"\n                  aria-expanded={false}\n                  onClick={() => setOpen(true)}\n                  onKeyDown={(e) => {\n                    if (e.key === \"ArrowDown\") {\n                      e.preventDefault();\n                      setOpen(true);\n                    }\n                  }}\n                  className={cn(TRIGGER, \"rounded-[20px] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\")}\n                  initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\", transition: { ...fade, delay: duration * 0.2 } }}\n                  exit={{ opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.1 } }}\n                >\n                  {icon}\n                  {label}\n                </motion.button>\n              )}\n            </AnimatePresence>\n          </div>\n        </motion.div>\n      </div>\n    </MotionConfig>\n  );\n}\n"}]}