diff --git a/animata/navigation/dropdown-menu.stories.tsx b/animata/navigation/dropdown-menu.stories.tsx new file mode 100644 index 00000000..7303099d --- /dev/null +++ b/animata/navigation/dropdown-menu.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HelpCircle, LogOut, Settings, User } from "lucide-react"; +import DropdownMenu, { type DropdownMenuProps } from "@/animata/navigation/dropdown-menu"; + +const meta = { + title: "Navigation/Dropdown Menu", + component: DropdownMenu, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + align: { + control: "select", + options: ["left", "right"], + description: "Dropdown alignment relative to trigger button", + }, + triggerLabel: { + control: "text", + description: "Label text for the trigger button", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + triggerLabel: "Options", + align: "left", + items: [ + { label: "Profile", icon: }, + { label: "Settings", icon: }, + { label: "Help", icon: }, + { label: "Sign Out", icon: }, + ], + }, + render: (args) => ( +
+ +
+ ), +}; + +export const RightAlign: Story = { + args: { + triggerLabel: "Menu", + align: "right", + items: [ + { label: "Profile", icon: }, + { label: "Settings", icon: }, + { label: "Help", icon: }, + { label: "Sign Out", icon: }, + ], + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/animata/navigation/dropdown-menu.tsx b/animata/navigation/dropdown-menu.tsx new file mode 100644 index 00000000..cf7eae6c --- /dev/null +++ b/animata/navigation/dropdown-menu.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +export interface MenuItem { + label: string; + icon?: React.ReactNode; + onClick?: () => void; +} + +export interface DropdownMenuProps { + items?: MenuItem[]; + triggerLabel?: string; + align?: "left" | "right"; +} + +const defaultItems: MenuItem[] = [ + { label: "Profile" }, + { label: "Settings" }, + { label: "Help" }, + { label: "Sign Out" }, +]; + +export default function DropdownMenu({ + items = defaultItems, + triggerLabel = "Options", + align = "left", +}: DropdownMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const prefersReducedMotion = useRef(false); + + useEffect(() => { + prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + }, []); + + useEffect(() => { + if (!isOpen) { + setSelectedIndex(0); + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % items.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + items.length) % items.length); + } else if (e.key === "Enter") { + e.preventDefault(); + items[selectedIndex]?.onClick?.(); + setIsOpen(false); + } else if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + triggerRef.current?.focus(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, selectedIndex, items]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + triggerRef.current && + !menuRef.current.contains(e.target as Node) && + !triggerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + const animationProps = prefersReducedMotion.current + ? {} + : { + initial: { opacity: 0, translateY: -8 }, + animate: { opacity: 1, translateY: 0 }, + exit: { opacity: 0, translateY: -8 }, + }; + + return ( +
+ + + + {isOpen && ( + + {items.map((item, index) => ( + + ))} + + )} + +
+ ); +} diff --git a/content/docs/navigation/dropdown-menu.mdx b/content/docs/navigation/dropdown-menu.mdx new file mode 100644 index 00000000..00e619e3 --- /dev/null +++ b/content/docs/navigation/dropdown-menu.mdx @@ -0,0 +1,84 @@ +--- +title: Dropdown Menu +description: A flexible dropdown menu with keyboard navigation, smooth open/close animation, and click-outside to close. +author: AnimataContributor +published: true +--- + + + +## Overview + +Dropdown Menu is a fully accessible, animated dropdown component. It supports keyboard navigation (Arrow Up/Down, Enter, Escape), click-outside to close, smooth framer-motion open/close animation, and respects prefers-reduced-motion. Works on both mouse and touch. + +## Features + +- **Trigger button** — opens and closes the dropdown panel +- **Smooth animation** — opacity + translateY via framer-motion +- **Keyboard navigation** — Arrow Up/Down, Enter to select, Escape to close +- **Click outside to close** — mousedown listener via ref + useEffect +- **prefers-reduced-motion** — animation disabled when set +- **Accessible** — aria-expanded, aria-haspopup, role="menu", role="menuitem" +- **Mobile ready** — tap to open/close, 44px minimum touch targets +- **Alignment** — left or right aligned dropdown panel + +## Usage + +```tsx +import DropdownMenu from "@/animata/navigation/dropdown-menu"; + +export default function Page() { + return ; +} +``` + +## Custom Items + +```tsx +import DropdownMenu from "@/animata/navigation/dropdown-menu"; + +export default function Page() { + return ( + console.log("profile") }, + { label: "Settings", onClick: () => console.log("settings") }, + { label: "Sign Out", onClick: () => console.log("signout") }, + ]} + /> + ); +} +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `items` | `MenuItem[]` | 4 default items | Array of menu items | +| `triggerLabel` | `string` | `"Options"` | Trigger button label | +| `align` | `"left" or "right"` | `"left"` | Dropdown panel alignment | + +## Types + +```typescript +interface MenuItem { + label: string; + icon?: React.ReactNode; + onClick?: () => void; +} +``` + +## Accessibility + +- `aria-haspopup="menu"` and `aria-expanded` on trigger button +- `role="menu"` on dropdown panel +- `role="menuitem"` on each item +- Focus returns to trigger on Escape +- All interactive elements meet 44px touch target minimum +- Respects prefers-reduced-motion + +## Credits + +Built by [Keen Sha](https://github.com/KeenIsHere).