-
Notifications
You must be signed in to change notification settings - Fork 222
feat (section): Add and Fixed animated pricing cards components #447
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
194bdee
297d911
09995c9
a85c24d
4989fc3
be345de
6f13cac
6c39e4b
3d61559
f7d7636
fcc45db
6bc1330
ee3b561
edbe9a7
5b135d2
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 |
|---|---|---|
| @@ -1 +1 @@ | ||
| NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6006 | ||
| NEXT_PUBLIC_STORYBOOK_URL=http://localhost:6007 |
| 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]); | ||
|
Comment on lines
+47
to
+67
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. Arrow keys update visual selection but do not move DOM focus — a11y gap. Arrow keys mutate Also applies to: 141-162 🤖 Prompt for AI Agents |
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
| 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> | ||
| ), | ||
| }; |
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.
🧩 Analysis chain
🏁 Script executed:
Repository: codse/animata
Length of output: 617
webpackFinalis a no-op that does not suppress Google Fonts errors.Both branches of the conditional on lines 21–25 return
pluginunchanged. The map overconfig.pluginsproduces no mutations, so this hook adds misleading configuration without actually addressing the stated problem.🤖 Prompt for AI Agents