diff --git a/AGENTS.md b/AGENTS.md index 4b14352..e826de7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,7 @@ Before building a new component, check this list. If it exists, import it. If it | `file-upload` | `components/motion/file-upload.tsx` | Drag-and-drop upload queue with progress rows and retry/remove actions | | `prediction-market` | `components/motion/prediction-market.tsx` | Trade ticket with buy/sell modes, outcome prices and rolling amount entry | | `otp-input` | `components/motion/otp-input.tsx` | One-time-code input with gliding focus ring, roll-in digits, error shake and success draw | +| `create-menu` | `components/motion/create-menu.tsx` | Button that morphs open into a grid menu via shared layout + clip-path, bouncy folder-style expand with staggered items | ### Site chrome (`components/app/` — not part of the library) diff --git a/components/motion/create-menu.tsx b/components/motion/create-menu.tsx new file mode 100644 index 0000000..8c3125c --- /dev/null +++ b/components/motion/create-menu.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + Calendar, + Files, + Flag, + FolderClosed, + NotebookPen, + Plus, + Trophy, + X, +} from "lucide-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: "Project", icon: FolderClosed }, + { label: "Notebook", icon: NotebookPen }, + { label: "Notes", icon: Files }, + { label: "Goal", icon: Trophy }, + { label: "Milestone", icon: Flag }, + { label: "Event", icon: Calendar }, +]; + +// Bouncy folder-open feel: low damping so the panel overshoots as it expands. +const SPRING_FOLDER = { + type: "spring", + stiffness: 320, + damping: 24, + mass: 0.9, +} as const; + +export interface CreateMenuProps { + items?: MenuItem[]; + onSelect?: (label: string) => void; + className?: string; +} + +export function CreateMenu({ + items = ITEMS, + onSelect, + className, +}: CreateMenuProps) { + 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 new + + +
+ + {/* grid */} + + {items.map((item, i) => ( + + ))} + +
+
+ ) : ( + setOpen(true)} + aria-haspopup="menu" + aria-expanded={open} + whileTap={reduce ? undefined : { scale: 0.97 }} + className="inline-flex h-12 w-44 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 new + + + + )} +
+
+
+ ); +} diff --git a/components/motion/tabs.tsx b/components/motion/tabs.tsx index 210dd0d..1e084b1 100644 --- a/components/motion/tabs.tsx +++ b/components/motion/tabs.tsx @@ -22,7 +22,8 @@ function useTabs() { return ctx; } -// Weighty spring — borrowed from dimi.me/lab/animated-tabs. +// Weighty spring for the active-tab indicator: a touch of overshoot so it +// settles with life instead of snapping. const transition: Transition = { type: "spring", stiffness: 170, diff --git a/components/previews/blocks/create-menu.preview.tsx b/components/previews/blocks/create-menu.preview.tsx new file mode 100644 index 0000000..7edd667 --- /dev/null +++ b/components/previews/blocks/create-menu.preview.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { CreateMenu } from "@/components/motion/create-menu"; + +export function CreateMenuPreview() { + return ( +
+ +
+ ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 079730f..9b181e1 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -13,6 +13,9 @@ export const previews: Record = { "blocks/command-palette": dynamic(() => import("./blocks/command-palette.preview").then((m) => m.CommandPalettePreview), ), + "blocks/create-menu": dynamic(() => + import("./blocks/create-menu.preview").then((m) => m.CreateMenuPreview), + ), "blocks/expandable-action-bar": dynamic(() => import("./blocks/expandable-action-bar.preview").then((m) => m.ExpandableActionBarPreview), ), diff --git a/lib/registry.ts b/lib/registry.ts index 16d6daa..f681c9d 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -428,6 +428,13 @@ export const registry: CategoryEntry[] = [ description: "One-time-code input with a gliding focus ring, digits that roll in per slot, error shake and a success check draw.", file: "components/motion/otp-input.tsx", }, + { + slug: "create-menu", + name: "Create Menu", + description: "A button that morphs open into a grid menu via shared layout and clip-path, with a bouncy folder-style expand and staggered items.", + file: "components/motion/create-menu.tsx", + badge: "new", + }, { slug: "not-found", name: "404 / Not Found", diff --git a/tests/a11y.test.tsx b/tests/a11y.test.tsx index 6b5b7c5..934fa92 100644 --- a/tests/a11y.test.tsx +++ b/tests/a11y.test.tsx @@ -6,6 +6,7 @@ import type { ReactElement } from "react"; import { AnimatedBadge } from "@/components/motion/animated-badge"; import { Button } from "@/components/motion/button"; import { Checkbox } from "@/components/motion/checkbox"; +import { CreateMenu } from "@/components/motion/create-menu"; import { RadioGroup, RadioGroupItem } from "@/components/motion/radio"; import { Switch } from "@/components/motion/switch"; import { Parallax } from "@/components/motion/parallax"; @@ -27,6 +28,7 @@ const cases: Array<[name: string, render: () => ReactElement]> = [ ["Button", () => ], ["Button disabled", () => ], ["Button ripple", () => ], + ["CreateMenu", () => ], [ "Switch", () => (