Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions animata/navigation/dropdown-menu.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DropdownMenu>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
args: {
triggerLabel: "Options",
align: "left",
items: [
{ label: "Profile", icon: <User className="h-4 w-4" /> },
{ label: "Settings", icon: <Settings className="h-4 w-4" /> },
{ label: "Help", icon: <HelpCircle className="h-4 w-4" /> },
{ label: "Sign Out", icon: <LogOut className="h-4 w-4" /> },
],
},
render: (args) => (
<div className="flex h-64 items-center justify-center">
<DropdownMenu {...args} />
</div>
),
};

export const RightAlign: Story = {
args: {
triggerLabel: "Menu",
align: "right",
items: [
{ label: "Profile", icon: <User className="h-4 w-4" /> },
{ label: "Settings", icon: <Settings className="h-4 w-4" /> },
{ label: "Help", icon: <HelpCircle className="h-4 w-4" /> },
{ label: "Sign Out", icon: <LogOut className="h-4 w-4" /> },
],
},
render: (args) => (
<div className="flex h-64 items-center justify-end pr-24">
<DropdownMenu {...args} />
</div>
),
};
168 changes: 168 additions & 0 deletions animata/navigation/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<div className="relative inline-block">
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="menu"
aria-expanded={isOpen}
className={cn(
"min-h-11 min-w-11 inline-flex items-center justify-center gap-2 rounded-lg",
"bg-background border border-border px-3 py-2 text-sm font-medium",
"text-foreground transition-colors duration-200",
"hover:bg-muted hover:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"dark:focus:ring-offset-background",
"active:scale-95",
)}
>
Comment on lines +95 to +111
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

A11y: wire up aria-controls/aria-activedescendant and handle Tab.

The trigger advertises aria-haspopup="menu" / aria-expanded, but the menu has no id and there is no aria-controls linking the two. Selection is only tracked via the selectedIndex state for visual highlight — DOM focus never moves and there's no aria-activedescendant, so screen readers won't announce the highlighted item as the user arrows through it. Tab is also not handled while the menu is open: focus can escape into the page while the menu remains visible.

Suggested minimum changes:

  • Generate a stable id (e.g., useId()) for the menu, set it on motion.div, and pass it as aria-controls on the trigger.
  • Set per-item ids (${menuId}-item-${index}) and reflect the current one via aria-activedescendant on the menu.
  • Close (or trap) on Tab in the keydown handler.
♿ Sketch of the changes
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
@@
   const [isOpen, setIsOpen] = useState(false);
   const [selectedIndex, setSelectedIndex] = useState(0);
+  const menuId = useId();
+  const activeItemId = `${menuId}-item-${selectedIndex}`;
@@
-      } else if (e.key === "Escape") {
+      } else if (e.key === "Escape" || e.key === "Tab") {
         e.preventDefault();
         setIsOpen(false);
         triggerRef.current?.focus();
       }
@@
       <button
         ref={triggerRef}
         type="button"
         onClick={() => setIsOpen(!isOpen)}
         aria-haspopup="menu"
         aria-expanded={isOpen}
+        aria-controls={menuId}
@@
           <motion.div
             ref={menuRef}
+            id={menuId}
             role="menu"
+            tabIndex={-1}
+            aria-activedescendant={activeItemId}
@@
               <button
                 key={index}
+                id={`${menuId}-item-${index}`}
                 type="button"
                 role="menuitem"

Note: preventing default on Tab will swallow the navigation; if you'd rather let focus move naturally, just call setIsOpen(false) without preventDefault().

Also applies to: 128-140, 141-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@animata/navigation/dropdown-menu.tsx` around lines 95 - 111, Generate a
stable menu id (e.g., with React's useId) and set it as the id on the menu
container (the motion.div rendered by the dropdown) and also set
aria-controls={menuId} on the trigger button (triggerRef block that calls
setIsOpen). Give each menu item an id like `${menuId}-item-${index}` and update
the menu container to expose the current selection via aria-activedescendant
using the selectedIndex state so screen readers announce the highlighted item.
In the dropdown keydown handler (the function that updates selectedIndex on
Arrow keys), handle Tab by closing the menu (call setIsOpen(false)) or trapping
focus (preventDefault and manage focus), and ensure aria-expanded remains
correct on the trigger; update references to selectedIndex, triggerRef,
setIsOpen, and the motion.div/menuId accordingly.

Comment on lines +97 to +111
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add type="button" to all <button> elements.

Without an explicit type, buttons default to type="submit". If this dropdown is rendered inside a <form>, clicking the trigger or any item will submit the form — silently breaking forms that use this navigation primitive.

🐛 Proposed fix
       <button
         ref={triggerRef}
+        type="button"
         onClick={() => setIsOpen(!isOpen)}
         aria-haspopup="menu"
         aria-expanded={isOpen}
               <button
                 key={index}
+                type="button"
                 role="menuitem"
                 onClick={() => {
                   item.onClick?.();
                   setIsOpen(false);
                 }}

Also applies to: 142-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@animata/navigation/dropdown-menu.tsx` around lines 97 - 111, The dropdown's
button elements (e.g., the trigger with ref={triggerRef} that toggles setIsOpen
and the item buttons later in this file) lack an explicit type and will default
to type="submit" inside forms; update every <button> in this component to
include type="button" so clicking the trigger or menu items won't submit a
surrounding form (add type="button" to the trigger button using triggerRef and
to each menu/item button instance).

{triggerLabel}
<svg
className={cn("h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</button>

<AnimatePresence>
{isOpen && (
<motion.div
ref={menuRef}
role="menu"
className={cn(
"absolute z-50 mt-2 min-w-48 overflow-hidden rounded-lg",
"border border-border bg-background shadow-lg",
"dark:border-border dark:bg-background",
align === "right" ? "right-0" : "left-0",
)}
{...animationProps}
>
{items.map((item, index) => (
<button
key={index}
role="menuitem"
onClick={() => {
item.onClick?.();
setIsOpen(false);
}}
className={cn(
"w-full min-h-11 inline-flex items-center gap-3 px-4 py-3",
"text-sm font-medium transition-colors duration-150",
"text-foreground hover:bg-muted hover:text-muted-foreground",
"focus:outline-none focus:bg-muted focus:text-muted-foreground",
"dark:text-foreground dark:hover:bg-muted dark:focus:bg-muted",
selectedIndex === index && "bg-muted text-muted-foreground",
)}
onMouseEnter={() => setSelectedIndex(index)}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.label}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
84 changes: 84 additions & 0 deletions content/docs/navigation/dropdown-menu.mdx
Original file line number Diff line number Diff line change
@@ -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
---

<ComponentPreview name="navigation-dropdown-menu--primary" />

## 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hyphenate compound modifier for readability.

Use “right-aligned dropdown panel” instead of “right aligned dropdown panel”.

🧰 Tools
🪛 LanguageTool

[grammar] ~23-~23: Use a hyphen to join words.
Context: ... targets - Alignment — left or right aligned dropdown panel ## Usage ```tsx...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@content/docs/navigation/dropdown-menu.mdx` at line 23, Update the sentence
that currently reads "Alignment — left or right aligned dropdown panel" to
hyphenate the compound modifier for readability by changing it to "Alignment —
left- or right-aligned dropdown panel" (or at minimum "right-aligned dropdown
panel"); locate and edit the exact string "left or right aligned dropdown panel"
in the navigation/dropdown-menu content and replace with the hyphenated form.


## Usage

```tsx
import DropdownMenu from "@/animata/navigation/dropdown-menu";

export default function Page() {
return <DropdownMenu />;
}
```

## Custom Items

```tsx
import DropdownMenu from "@/animata/navigation/dropdown-menu";

export default function Page() {
return (
<DropdownMenu
triggerLabel="Account"
align="right"
items={[
{ label: "Profile", onClick: () => 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).
Loading