Skip to content
This repository was archived by the owner on Jul 20, 2025. It is now read-only.

Commit 32886c0

Browse files
feat: add menu component
1 parent 14c2143 commit 32886c0

File tree

6 files changed

+351
-0
lines changed

6 files changed

+351
-0
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { Menu } from "./Menu";
3+
import { MenuItem } from "./MenuItem";
4+
5+
const meta: Meta<typeof Menu> = {
6+
title: "Components/Menu",
7+
component: Menu,
8+
tags: ["autodocs"],
9+
parameters: {
10+
docs: {
11+
description: {
12+
component: "A responsive menu component that renders as a popover on desktop and a full-screen dialog on mobile.",
13+
},
14+
},
15+
},
16+
};
17+
18+
export default meta;
19+
20+
type Story = StoryObj<typeof meta>;
21+
22+
export const Default: Story = {
23+
args: {
24+
trigger: (
25+
<button className="flex items-center justify-center w-10 h-10 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
26+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
27+
<path d="M27.8999 17.5667C27.9665 17.0667 27.9999 16.55 27.9999 16C27.9999 15.4667 27.9665 14.9333 27.8832 14.4333L31.2665 11.8C31.5665 11.5667 31.6499 11.1167 31.4665 10.7833L28.2665 5.25C28.0665 4.88333 27.6499 4.76667 27.2832 4.88333L23.2999 6.48333C22.4665 5.85 21.5832 5.31667 20.5999 4.91667L19.9999 0.683333C19.9332 0.283333 19.5999 0 19.1999 0H12.7999C12.3999 0 12.0832 0.283333 12.0165 0.683333L11.4165 4.91667C10.4332 5.31667 9.53319 5.86667 8.71652 6.48333L4.73319 4.88333C4.36652 4.75 3.94986 4.88333 3.74986 5.25L0.566524 10.7833C0.366524 11.1333 0.43319 11.5667 0.766524 11.8L4.14986 14.4333C4.06652 14.9333 3.99986 15.4833 3.99986 16C3.99986 16.5167 4.03319 17.0667 4.11652 17.5667L0.733191 20.2C0.433191 20.4333 0.349857 20.8833 0.533191 21.2167L3.73319 26.75C3.93319 27.1167 4.34986 27.2333 4.71652 27.1167L8.69986 25.5167C9.53319 26.15 10.4165 26.6833 11.3999 27.0833L11.9999 31.3167C12.0832 31.7167 12.3999 32 12.7999 32H19.1999C19.5999 32 19.9332 31.7167 19.9832 31.3167L20.5832 27.0833C21.5665 26.6833 22.4665 26.15 23.2832 25.5167L27.2665 27.1167C27.6332 27.25 28.0499 27.1167 28.2499 26.75L31.4499 21.2167C31.6499 20.85 31.5665 20.4333 31.2499 20.2L27.8999 17.5667ZM15.9999 22C12.6999 22 9.99986 19.3 9.99986 16C9.99986 12.7 12.6999 10 15.9999 10C19.2999 10 21.9999 12.7 21.9999 16C21.9999 19.3 19.2999 22 15.9999 22Z" fill="#9E9E9E" />
28+
</svg>
29+
</button>
30+
),
31+
},
32+
render: (args) => {
33+
// Mock icons for the story
34+
const ThemeIcon = () => (
35+
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
36+
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
37+
<path d="M10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20ZM11 2.07C14.94 2.56 18 5.92 18 10C18 14.08 14.95 17.44 11 17.93V2.07Z" />
38+
</svg>
39+
</div>
40+
);
41+
42+
const WarningIcon = () => (
43+
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
44+
<svg width="16" height="16" viewBox="0 0 18 18" fill="currentColor">
45+
<path d="M12.73 0H5.27L0 5.27V12.73L5.27 18H12.73L18 12.73V5.27L12.73 0ZM9 14.3C8.28 14.3 7.7 13.72 7.7 13C7.7 12.28 8.28 11.7 9 11.7C9.72 11.7 10.3 12.28 10.3 13C10.3 13.72 9.72 14.3 9 14.3ZM10 10H8V4H10V10Z" />
46+
</svg>
47+
</div>
48+
);
49+
50+
const ChevronIcon = () => (
51+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
52+
<path d="M6 4L10 8L6 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
53+
</svg>
54+
);
55+
56+
return (
57+
<Menu {...args}>
58+
<MenuItem
59+
icon={<ThemeIcon />}
60+
name="Theme"
61+
description="Light"
62+
onClick={() => console.log("Theme clicked")}
63+
showChevron={true}
64+
chevronIcon={<ChevronIcon />}
65+
/>
66+
<MenuItem
67+
icon={<WarningIcon />}
68+
name="Report a Bug"
69+
onClick={() => console.log("Report bug clicked")}
70+
/>
71+
<MenuItem name="Terms of Service" />
72+
<MenuItem name="Privacy Policy" />
73+
<div className="p-4">
74+
<button className="w-full py-3 px-4 rounded bg-[#D5FCE8] text-black hover:bg-[#D5FCE8]/90 font-medium">
75+
Switch to BABY Staking
76+
</button>
77+
</div>
78+
</Menu>
79+
);
80+
},
81+
};
82+

src/components/Menu/Menu.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React, { useState, useRef, useEffect, useCallback } from "react";
2+
import { twJoin } from "tailwind-merge";
3+
4+
import { MobileDialog } from "../Dialog";
5+
import { Popover, PopoverPlacement } from "../Popover";
6+
import { MenuProvider } from "./MenuContext";
7+
8+
interface MenuProps {
9+
children: React.ReactNode;
10+
trigger?: React.ReactNode;
11+
open?: boolean;
12+
onOpenChange?: (open: boolean) => void;
13+
placement?: PopoverPlacement;
14+
offset?: [number, number];
15+
className?: string;
16+
}
17+
18+
export const Menu: React.FC<MenuProps> = ({
19+
children,
20+
trigger,
21+
open: controlledOpen,
22+
onOpenChange,
23+
placement = "bottom-end",
24+
offset = [0, 8],
25+
className,
26+
}) => {
27+
const [internalOpen, setInternalOpen] = useState(false);
28+
const triggerRef = useRef<HTMLDivElement>(null);
29+
const [isMobile, setIsMobile] = useState(false);
30+
31+
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
32+
const setIsOpen = (open: boolean) => {
33+
if (controlledOpen === undefined) {
34+
setInternalOpen(open);
35+
}
36+
onOpenChange?.(open);
37+
};
38+
39+
const onClose = () => setIsOpen(false);
40+
41+
// Check if mobile view - we'll need to use the same breakpoint logic as simple-staking
42+
useEffect(() => {
43+
const checkMobile = () => {
44+
setIsMobile(window.innerWidth < 768);
45+
};
46+
47+
checkMobile();
48+
window.addEventListener("resize", checkMobile);
49+
return () => window.removeEventListener("resize", checkMobile);
50+
}, []);
51+
52+
// Handle escape key
53+
useEffect(() => {
54+
const handleEscape = (e: KeyboardEvent) => {
55+
if (e.key === "Escape" && isOpen) {
56+
onClose();
57+
}
58+
};
59+
60+
if (isOpen) {
61+
document.addEventListener("keydown", handleEscape);
62+
return () => document.removeEventListener("keydown", handleEscape);
63+
}
64+
}, [isOpen]);
65+
66+
const menuContent = <div className="w-full text-primary-main">{children}</div>;
67+
68+
const triggerElement = trigger || (
69+
<button
70+
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
71+
onClick={() => setIsOpen(!isOpen)}
72+
>
73+
Menu
74+
</button>
75+
);
76+
77+
return (
78+
<MenuProvider value={{ isOpen, setIsOpen, onClose, isMobile }}>
79+
<div ref={triggerRef} className="inline-block">
80+
{React.isValidElement(triggerElement) &&
81+
React.cloneElement(triggerElement as React.ReactElement, {
82+
onClick: () => setIsOpen(!isOpen),
83+
"aria-haspopup": "true",
84+
"aria-expanded": isOpen,
85+
})
86+
}
87+
</div>
88+
89+
{isMobile ? (
90+
<MobileDialog
91+
open={isOpen}
92+
onClose={onClose}
93+
className="bg-[#FFFFFF] dark:bg-[#252525] text-primary-main p-0"
94+
>
95+
{menuContent}
96+
</MobileDialog>
97+
) : (
98+
<Popover
99+
anchorEl={triggerRef.current}
100+
open={isOpen}
101+
offset={offset}
102+
placement={placement}
103+
onClickOutside={onClose}
104+
className={twJoin(
105+
"shadow-lg border border-[#38708533] bg-[#FFFFFF] dark:bg-[#252525] dark:border-[#404040] rounded-lg",
106+
"min-w-[294px]",
107+
className,
108+
)}
109+
>
110+
{menuContent}
111+
</Popover>
112+
)}
113+
</MenuProvider>
114+
);
115+
};

src/components/Menu/MenuButton.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React, { HTMLAttributes } from "react";
2+
import { twJoin } from "tailwind-merge";
3+
4+
interface MenuButtonProps extends HTMLAttributes<HTMLButtonElement> {
5+
children?: React.ReactNode;
6+
className?: string;
7+
}
8+
9+
export const MenuButton: React.FC<MenuButtonProps> = ({
10+
children,
11+
className,
12+
...props
13+
}) => {
14+
return (
15+
<button
16+
className={twJoin(
17+
"inline-flex items-center justify-center",
18+
"p-2 rounded-md",
19+
"hover:bg-gray-100 dark:hover:bg-gray-800",
20+
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500",
21+
"transition-colors",
22+
className,
23+
)}
24+
aria-haspopup="true"
25+
{...props}
26+
>
27+
{children || "Menu"}
28+
</button>
29+
);
30+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React, { createContext, useContext } from "react";
2+
3+
interface MenuContextValue {
4+
isOpen: boolean;
5+
setIsOpen: (open: boolean) => void;
6+
onClose: () => void;
7+
isMobile: boolean;
8+
}
9+
10+
const MenuContext = createContext<MenuContextValue | undefined>(undefined);
11+
12+
export const useMenuContext = () => {
13+
const context = useContext(MenuContext);
14+
if (!context && process.env.NODE_ENV === "development") {
15+
console.warn("useMenuContext must be used within a Menu component");
16+
}
17+
return context;
18+
};
19+
20+
export const MenuProvider: React.FC<{
21+
children: React.ReactNode;
22+
value: MenuContextValue;
23+
}> = ({ children, value }) => {
24+
return <MenuContext.Provider value={value}>{children}</MenuContext.Provider>;
25+
};

src/components/Menu/MenuItem.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from "react";
2+
import { twJoin } from "tailwind-merge";
3+
4+
import { Text } from "../Text";
5+
import { useMenuContext } from "./MenuContext";
6+
7+
interface MenuItemProps {
8+
icon?: React.ReactNode;
9+
name: string;
10+
description?: string;
11+
onClick?: () => void;
12+
disabled?: boolean;
13+
className?: string;
14+
children?: React.ReactNode;
15+
showChevron?: boolean;
16+
chevronIcon?: React.ReactNode;
17+
}
18+
19+
export const MenuItem: React.FC<MenuItemProps> = ({
20+
icon,
21+
name,
22+
description,
23+
onClick,
24+
disabled = false,
25+
className,
26+
children,
27+
showChevron = false,
28+
chevronIcon,
29+
}) => {
30+
const menuContext = useMenuContext();
31+
32+
const handleClick = () => {
33+
if (disabled || !onClick) return;
34+
onClick();
35+
// Close menu after click unless it opens a submenu
36+
if (menuContext && !showChevron) {
37+
menuContext.onClose();
38+
}
39+
};
40+
41+
// If children are provided, render them instead of the default layout
42+
if (children) {
43+
return (
44+
<button
45+
onClick={handleClick}
46+
disabled={disabled}
47+
className={twJoin(
48+
"w-full text-left",
49+
disabled && "opacity-50 cursor-not-allowed",
50+
className,
51+
)}
52+
>
53+
{children}
54+
</button>
55+
);
56+
}
57+
58+
return (
59+
<button
60+
onClick={handleClick}
61+
disabled={disabled}
62+
className={twJoin(
63+
"flex items-center justify-between w-full p-6",
64+
"hover:bg-accent-secondary/5 transition-colors",
65+
"text-left",
66+
"focus:outline-none",
67+
disabled && "opacity-50 cursor-not-allowed",
68+
className,
69+
)}
70+
role="menuitem"
71+
tabIndex={disabled ? -1 : 0}
72+
>
73+
<div className="flex items-center gap-3">
74+
{icon && <div className="flex-shrink-0">{icon}</div>}
75+
<div className="flex flex-col">
76+
<Text
77+
variant="body1"
78+
className="text-accent-primary font-medium text-sm"
79+
>
80+
{name}
81+
</Text>
82+
{description && (
83+
<Text variant="body2" className="text-accent-secondary text-xs">
84+
{description}
85+
</Text>
86+
)}
87+
</div>
88+
</div>
89+
{showChevron && chevronIcon}
90+
</button>
91+
);
92+
};

src/components/Menu/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { Menu } from "./Menu";
2+
export { MenuItem } from "./MenuItem";
3+
export { MenuButton } from "./MenuButton";
4+
export { useMenuContext } from "./MenuContext";
5+
6+
// Re-export types
7+
export type { PopoverPlacement } from "../Popover";

0 commit comments

Comments
 (0)