-
Notifications
You must be signed in to change notification settings - Fork 222
feat(navigation): add dropdown menu component with keyboard nav and animations #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ae78b9c
517fd8f
a419626
0d0c585
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ), | ||
| }; |
| 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
+97
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add Without an explicit 🐛 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 |
||
| {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> | ||
| ); | ||
| } | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. (QB_NEW_EN_HYPHEN) 🤖 Prompt for AI Agents |
||
|
|
||
| ## 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). | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A11y: wire up
aria-controls/aria-activedescendantand handleTab.The trigger advertises
aria-haspopup="menu"/aria-expanded, but the menu has noidand there is noaria-controlslinking the two. Selection is only tracked via theselectedIndexstate for visual highlight — DOM focus never moves and there's noaria-activedescendant, so screen readers won't announce the highlighted item as the user arrows through it.Tabis also not handled while the menu is open: focus can escape into the page while the menu remains visible.Suggested minimum changes:
useId()) for the menu, set it onmotion.div, and pass it asaria-controlson the trigger.${menuId}-item-${index}) and reflect the current one viaaria-activedescendanton the menu.Tabin the keydown handler.♿ Sketch of the changes
Note: preventing default on
Tabwill swallow the navigation; if you'd rather let focus move naturally, just callsetIsOpen(false)withoutpreventDefault().Also applies to: 128-140, 141-162
🤖 Prompt for AI Agents