From ae78b9ccdb40c83d505d82be24dd2f1721fb6e0a Mon Sep 17 00:00:00 2001 From: Keen Sha Date: Sun, 26 Apr 2026 16:13:39 +0545 Subject: [PATCH 1/4] feat: add dropdown menu component with keyboard nav and animations --- animata/navigation/dropdown-menu.stories.tsx | 62 +++++++ animata/navigation/dropdown-menu.tsx | 168 +++++++++++++++++++ content/docs/navigation/dropdown-menu.mdx | 25 +++ 3 files changed, 255 insertions(+) create mode 100644 animata/navigation/dropdown-menu.stories.tsx create mode 100644 animata/navigation/dropdown-menu.tsx create mode 100644 content/docs/navigation/dropdown-menu.mdx 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..f30b8359 --- /dev/null +++ b/content/docs/navigation/dropdown-menu.mdx @@ -0,0 +1,25 @@ +--- +title: Dropdown Menu +description: A flexible dropdown menu with keyboard navigation and smooth animations. +labels: ["requires interaction", "click"] +--- + +## Usage + +Copy the component into your project and import it. + +```typescript +import DropdownMenu from "@/animata/navigation/dropdown-menu"; +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| items | MenuItem[] | [] | Array of menu items | +| triggerLabel | string | "Options" | Button label | +| align | "left" \| "right" | "left" | Dropdown alignment | + +## Accessibility + +Fully keyboard navigable. Respects prefers-reduced-motion. \ No newline at end of file From 517fd8f7aba0585c8308b8841c89cf691873e496 Mon Sep 17 00:00:00 2001 From: Keen Sha Date: Wed, 29 Apr 2026 09:44:32 +0545 Subject: [PATCH 2/4] fix: add published: true and improve dropdown menu docs --- content/docs/navigation/dropdown-menu.mdx | 74 ++++++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/content/docs/navigation/dropdown-menu.mdx b/content/docs/navigation/dropdown-menu.mdx index f30b8359..4c5d3fa4 100644 --- a/content/docs/navigation/dropdown-menu.mdx +++ b/content/docs/navigation/dropdown-menu.mdx @@ -1,25 +1,81 @@ --- title: Dropdown Menu -description: A flexible dropdown menu with keyboard navigation and smooth animations. -labels: ["requires interaction", "click"] +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 -Copy the component into your project and import it. +```tsx +import DropdownMenu from "@/animata/navigation/dropdown-menu"; -```typescript +export default function Page() { + return ; +} +``` + +## Custom Items + +```tsx +import { User, Settings, LogOut } from "lucide-react"; import DropdownMenu from "@/animata/navigation/dropdown-menu"; + +export default function Page() { + return ( + , onClick: () => {} }, + { label: "Settings", icon: , onClick: () => {} }, + { label: "Sign Out", icon: , onClick: () => {} }, + ]} + /> + ); +} ``` ## Props | Prop | Type | Default | Description | -|---|---|---|---| -| items | MenuItem[] | [] | Array of menu items | -| triggerLabel | string | "Options" | Button label | -| align | "left" \| "right" | "left" | Dropdown alignment | +|------|------|---------|-------------| +| `items` | `MenuItem[]` | 4 default items | Array of menu items | +| `triggerLabel` | `string` | `"Options"` | Trigger button label | +| `align` | `"left" \| "right"` | `"left"` | Dropdown panel alignment | + +## Types + +```typescript +interface MenuItem { + label: string; + icon?: React.ReactNode; + onClick?: () => void; +} +``` ## Accessibility -Fully keyboard navigable. Respects prefers-reduced-motion. \ No newline at end of file +- `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 From a419626c610ee62f91026f3180039bc9a67b6679 Mon Sep 17 00:00:00 2001 From: Keen Sha Date: Wed, 29 Apr 2026 09:48:17 +0545 Subject: [PATCH 3/4] fix: remove JSX from mdx code blocks to fix acorn parse error --- content/docs/navigation/dropdown-menu.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/content/docs/navigation/dropdown-menu.mdx b/content/docs/navigation/dropdown-menu.mdx index 4c5d3fa4..152ab66e 100644 --- a/content/docs/navigation/dropdown-menu.mdx +++ b/content/docs/navigation/dropdown-menu.mdx @@ -35,7 +35,6 @@ export default function Page() { ## Custom Items ```tsx -import { User, Settings, LogOut } from "lucide-react"; import DropdownMenu from "@/animata/navigation/dropdown-menu"; export default function Page() { @@ -44,9 +43,9 @@ export default function Page() { triggerLabel="Account" align="right" items={[ - { label: "Profile", icon: , onClick: () => {} }, - { label: "Settings", icon: , onClick: () => {} }, - { label: "Sign Out", icon: , onClick: () => {} }, + { label: "Profile", onClick: () => console.log("profile") }, + { label: "Settings", onClick: () => console.log("settings") }, + { label: "Sign Out", onClick: () => console.log("signout") }, ]} /> ); @@ -59,7 +58,7 @@ export default function Page() { |------|------|---------|-------------| | `items` | `MenuItem[]` | 4 default items | Array of menu items | | `triggerLabel` | `string` | `"Options"` | Trigger button label | -| `align` | `"left" \| "right"` | `"left"` | Dropdown panel alignment | +| `align` | `"left" or "right"` | `"left"` | Dropdown panel alignment | ## Types From 0d0c585d1fe6e801731a2284576a1f1c02709a3e Mon Sep 17 00:00:00 2001 From: Keen Sha Date: Wed, 29 Apr 2026 10:02:29 +0545 Subject: [PATCH 4/4] docs: add credits section with GitHub profile link --- content/docs/navigation/dropdown-menu.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/content/docs/navigation/dropdown-menu.mdx b/content/docs/navigation/dropdown-menu.mdx index 152ab66e..00e619e3 100644 --- a/content/docs/navigation/dropdown-menu.mdx +++ b/content/docs/navigation/dropdown-menu.mdx @@ -78,3 +78,7 @@ interface MenuItem { - 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).