Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1 +1 @@
NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6006
NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6007
21 changes: 20 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,27 @@ const config: StorybookConfig = {
addons: ["@storybook/addon-themes", "@storybook/addon-docs"],
framework: {
name: "@storybook/nextjs",
options: {},
options: {
nextConfigPath: "./next.config.mjs",
},
},
tags: {},
webpackFinal: async (config) => {
// Suppress Google Fonts loading errors during dev
if (config.plugins) {
config.plugins = config.plugins.map((plugin) => {
if (
plugin &&
typeof plugin === "object" &&
"constructor" in plugin &&
plugin.constructor.name === "ProgressPlugin"
) {
return plugin;
}
return plugin;
});
}
return config;
},
Comment on lines +13 to +29
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
sed -n '13,29p' .storybook/main.ts
rg -n 'return plugin;|constructor.name === "ProgressPlugin"' .storybook/main.ts

Repository: codse/animata

Length of output: 617


webpackFinal is a no-op that does not suppress Google Fonts errors.

Both branches of the conditional on lines 21–25 return plugin unchanged. The map over config.plugins produces no mutations, so this hook adds misleading configuration without actually addressing the stated problem.

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

In @.storybook/main.ts around lines 13 - 29, The webpackFinal hook is a no-op:
mapping config.plugins and returning each plugin unchanged (including the
ProgressPlugin) doesn't suppress Google Fonts errors; either remove the entire
webpackFinal block or implement real suppression by mutating config.plugins
(e.g., filter out or replace the plugin that triggers Google Fonts loads)
instead of the current identity map. Locate webpackFinal and the config.plugins
map and replace the map with logic that filters out the specific plugin by
constructor.name (or returns a stubbed plugin) that causes Google Fonts errors
(or simply delete the hook if no suppression is needed) so the hook actually
changes config.plugins rather than leaving it unchanged.

};
export default config;
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]);
Comment on lines +47 to +67
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

Arrow keys update visual selection but do not move DOM focus — a11y gap.

Arrow keys mutate selectedIndex (visual highlight only) while DOM focus remains on the trigger button. Screen readers won't announce the active menuitem, and Tab order isn't synchronized with the highlighted row. Since the docs (dropdown-menu.mdx) advertise full keyboard navigation, consider focusing the matching <button role="menuitem"> when selectedIndex changes (e.g., via an array of refs and .focus() in an effect), and let Enter rely on the focused button.

Also applies to: 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 47 - 67, The arrow-key
handler currently updates selectedIndex visually but doesn't move DOM focus; fix
by creating an array of refs for the menu item buttons (e.g., menuItemRefs) and,
in an effect that runs when selectedIndex or isOpen changes, call
menuItemRefs[selectedIndex]?.current?.focus() when the menu is open so the
corresponding <button role="menuitem"> receives focus; update the Enter handling
in handleKeyDown to rely on the focused button (or call
menuItemRefs[selectedIndex]?.current?.click()) instead of directly invoking
items[selectedIndex]?.onClick, and ensure you create/cleanup refs and keep
triggerRef focus behavior on Escape (triggerRef.current?.focus()); apply the
same ref/focus pattern to the other menu instance referenced similarly in this
file.


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",
)}
>
{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>
);
}
74 changes: 74 additions & 0 deletions animata/section/animated-pricing-cards.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import AnimatedPricingCards, {
type AnimatedPricingCardsProps,
} from "@/animata/section/animated-pricing-cards";

const meta = {
title: "Section/Animated Pricing Cards",
component: AnimatedPricingCards,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
} satisfies Meta<typeof AnimatedPricingCards>;

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

export const Primary: Story = {
args: {},
render: (args: AnimatedPricingCardsProps) => (
<div className="full-content w-full bg-background">
<AnimatedPricingCards {...args} />
</div>
),
};

export const CustomPlans: Story = {
args: {
plans: [
{
name: "Hobby",
price: "$9",
period: "/month",
description: "For weekend warriors",
features: ["1 project", "1GB storage", "Email support"],
ctaText: "Start Building",
},
{
name: "Business",
price: "$99",
period: "/month",
description: "For growing businesses",
features: [
"50 projects",
"500GB storage",
"Priority support",
"Team access",
"Advanced security",
],
highlighted: true,
ctaText: "Start 14-Day Trial",
},
{
name: "Custom",
price: "Let's talk",
period: "contact us",
description: "For large organizations",
features: [
"Everything in Business",
"Unlimited projects",
"Dedicated account manager",
"Custom SLA",
"Enterprise security",
],
ctaText: "Schedule Demo",
},
],
},
render: (args: AnimatedPricingCardsProps) => (
<div className="full-content w-full bg-background">
<AnimatedPricingCards {...args} />
</div>
),
};
Loading
Loading