"use client"; import { Bell, FileText, FolderClosed, LayoutGrid, Link, Plus, Table, X, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ComponentType, useEffect, useId, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; type MenuItem = { label: string; icon: ComponentType<{ className?: string }> }; const ITEMS: MenuItem[] = [ { label: "Doc", icon: FileText }, { label: "Board", icon: LayoutGrid }, { label: "Table", icon: Table }, { label: "Folder", icon: FolderClosed }, { label: "Reminder", icon: Bell }, { label: "Link", icon: Link }, ]; // Folder-open feel: a touch of overshoot as the panel expands, kept subtle. const SPRING_FOLDER = { type: "spring", stiffness: 300, damping: 32, mass: 0.9, } as const; export interface BloomMenuProps { items?: MenuItem[]; onSelect?: (label: string) => void; className?: string; } export function BloomMenu({ items = ITEMS, onSelect, className, }: BloomMenuProps) { const [open, setOpen] = useState(false); const reduce = useReducedMotion(); const layoutId = useId(); const ref = useRef(null); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; const onPointer = (e: PointerEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; window.addEventListener("keydown", onKey); window.addEventListener("pointerdown", onPointer); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("pointerdown", onPointer); }; }, [open]); const morph = reduce ? { duration: 0.15 } : SPRING_FOLDER; return (
{/* spacer fixes the anchor to the trigger size */}
{/* Centering box sized to the OPEN panel and centered on the trigger. place-items-center only centers an item that fits its cell, so the cell must be as wide as the panel — otherwise the overflow left-anchors and the panel expands rightward. The box is a fixed size per viewport (vw doesn't change mid-animation), so its -translate centering never drifts the way a content-sized wrapper would. Both states share its center, so the morph grows from the middle outward in every direction. */}
{/* popLayout pulls the exiting trigger out of grid flow at once, so the grid never briefly holds two rows and shoves the panel off-center */} {open ? ( {/* header */}
Create
{/* grid */} {items.map((item, i) => { // Radial stagger: delay each item by its distance from the // grid center so the four corners animate together and the // open reads as center-out, not corner-by-corner. const cols = 3; const rows = Math.ceil(items.length / cols); const col = i % cols; const row = Math.floor(i / cols); const dist = Math.hypot( col - (cols - 1) / 2, row - (rows - 1) / 2, ); return ( ); })}
) : ( setOpen(true)} aria-haspopup="menu" aria-expanded={open} whileTap={reduce ? undefined : { scale: 0.97 }} className="inline-flex h-11 w-36 items-center justify-center border border-border bg-card text-sm font-medium text-foreground" > {/* own `layout` counter-scales the label so it stays crisp while the button box morphs, instead of stretching with it */} Create )}
); }