Skip to content

Commit 82662af

Browse files
committed
feat: add menu component
1 parent 460f83e commit 82662af

File tree

5 files changed

+543
-241
lines changed

5 files changed

+543
-241
lines changed

packages/ui-react/package.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,16 @@
3030
"author": "",
3131
"license": "MIT",
3232
"dependencies": {
33-
"@base-ui-components/react": "1.0.0-beta.0",
34-
"@tailwindcss/vite": "^4.1.7",
33+
"@base-ui-components/react": "1.0.0-beta.1",
34+
"@tailwindcss/vite": "^4.1.11",
3535
"@tanstack/react-table": "^8.21.3",
3636
"class-variance-authority": "^0.7.1",
3737
"clsx": "^2.1.1",
38-
"lucide-react": "^0.511.0",
38+
"lucide-react": "^0.525.0",
3939
"react": "^19.1.0",
40-
"sonner": "^2.0.3",
41-
"tailwind-merge": "^3.3.0",
42-
"tailwindcss": "^4.1.7"
40+
"sonner": "^2.0.6",
41+
"tailwind-merge": "^3.3.1",
42+
"tailwindcss": "^4.1.11"
4343
},
4444
"devDependencies": {
4545
"@storybook/addon-a11y": "^8.6.14",
@@ -50,16 +50,16 @@
5050
"@storybook/react": "8.6.14",
5151
"@storybook/react-vite": "8.6.14",
5252
"@storybook/test": "8.6.14",
53-
"@tailwindcss/cli": "^4.1.7",
54-
"@types/react": "^19.1.4",
55-
"@vitest/browser": "^3.1.3",
56-
"@vitest/coverage-v8": "^3.1.3",
53+
"@tailwindcss/cli": "^4.1.11",
54+
"@types/react": "^19.1.8",
55+
"@vitest/browser": "^3.2.4",
56+
"@vitest/coverage-v8": "^3.2.4",
5757
"copyfiles": "^2.4.1",
58-
"playwright": "^1.52.0",
58+
"playwright": "^1.53.2",
5959
"prop-types": "15.8.1",
6060
"storybook": "8.6.14",
6161
"typescript": "^5.8.3",
6262
"vite": "^6.3.5",
63-
"vitest": "^3.1.3"
63+
"vitest": "^3.2.4"
6464
}
6565
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Meta, Primary, Controls } from '@storybook/addon-docs';
2+
import stories from './menu.stories'
3+
4+
<Meta of={stories} />
5+
6+
# Menu
7+
8+
The Menu component is used to display a list of options when a button or other
9+
trigger element is clicked. It extends the [Base UI Menu
10+
component](https://base-ui.com/react/components/menu).
11+
12+
<Primary />
13+
<Controls />
14+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Meta, Preview } from "@storybook/react";
2+
import { Button } from "../button/button.js";
3+
import * as Menu from "./menu.js";
4+
5+
export default {
6+
title: "Components/Menu",
7+
component: Menu.Root,
8+
argTypes: {
9+
children: {
10+
control: false,
11+
},
12+
},
13+
decorators: [
14+
(Story) => (
15+
<div className="p-40 max-w-lg">
16+
<Story />
17+
</div>
18+
),
19+
],
20+
} satisfies Meta;
21+
22+
export const Simple = {
23+
args: {
24+
children: (
25+
<>
26+
<Menu.Trigger render={<Button>Open Menu</Button>} />
27+
<Menu.Positioner>
28+
<Menu.Item>Option 1</Menu.Item>
29+
<Menu.Item>Option 2</Menu.Item>
30+
<Menu.Separator />
31+
<Menu.Item>Option 3</Menu.Item>
32+
</Menu.Positioner>
33+
</>
34+
),
35+
},
36+
} satisfies Preview;
37+
38+
export const Nested = {
39+
args: {
40+
children: (
41+
<>
42+
<Menu.Trigger render={<Button>Open Menu</Button>} />
43+
<Menu.Positioner>
44+
<Menu.Item>Option 1</Menu.Item>
45+
<Menu.Item>Option 2</Menu.Item>
46+
<Menu.Separator />
47+
48+
<Menu.SubmenuRoot>
49+
<Menu.SubmenuTrigger render={<Menu.Item>Submenu</Menu.Item>} />
50+
<Menu.Positioner>
51+
<Menu.Item>Option 1</Menu.Item>
52+
<Menu.Item>Option 2</Menu.Item>
53+
</Menu.Positioner>
54+
</Menu.SubmenuRoot>
55+
</Menu.Positioner>
56+
</>
57+
),
58+
},
59+
} satisfies Preview;
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { Menu as MenuPrimitive } from "@base-ui-components/react/menu";
2+
import { ChevronRightIcon, ChevronsRightIcon } from "lucide-react";
3+
import { createContext, useContext } from "react";
4+
import { cn } from "../../lib/utils.js";
5+
6+
const Root = MenuPrimitive.Root;
7+
const Trigger = MenuPrimitive.Trigger;
8+
const Group = MenuPrimitive.Group;
9+
const SubmenuRoot = MenuPrimitive.SubmenuRoot;
10+
const SubmenuTrigger = MenuPrimitive.SubmenuTrigger;
11+
12+
function ArrowSvg(props: React.ComponentProps<"svg">) {
13+
return (
14+
// biome-ignore lint/a11y/noSvgWithoutTitle: This is only for display
15+
<svg
16+
aria-hidden
17+
width="20"
18+
height="10"
19+
viewBox="0 0 20 10"
20+
fill="none"
21+
{...props}
22+
>
23+
{/* Border */}
24+
<path
25+
d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
26+
className="fill-gray-100"
27+
/>
28+
29+
{/* Background */}
30+
<path
31+
d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
32+
className="fill-gray-300"
33+
/>
34+
35+
{/* Border with same background color to create rounded edges */}
36+
<path
37+
d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
38+
className="fill-gray-100"
39+
/>
40+
</svg>
41+
);
42+
}
43+
44+
function Arrow() {
45+
return (
46+
<MenuPrimitive.Arrow
47+
className={`
48+
group-data-[nested]/popup:hidden
49+
data-[side=top]:-bottom-[8px]
50+
data-[side=top]:rotate-180
51+
data-[side=bottom]:-top-[8px]
52+
data-[side=bottom]:rotate-0
53+
data-[side=left]:-right-[13px]
54+
data-[side=left]:rotate-90
55+
data-[side=right]:-left-[13px]
56+
data-[side=right]:-rotate-90
57+
`}
58+
>
59+
<ArrowSvg />
60+
</MenuPrimitive.Arrow>
61+
);
62+
}
63+
64+
const SubmenuContext = createContext<{
65+
isNested: boolean;
66+
}>({
67+
isNested: false,
68+
});
69+
70+
const Positioner = ({
71+
className,
72+
children,
73+
...props
74+
}: MenuPrimitive.Positioner.Props) => {
75+
const submenuContext = useContext(SubmenuContext);
76+
77+
return (
78+
<SubmenuContext.Provider
79+
value={{
80+
isNested: true,
81+
}}
82+
>
83+
<MenuPrimitive.Portal>
84+
<MenuPrimitive.Positioner
85+
alignOffset={submenuContext.isNested ? -6 : 0}
86+
sideOffset={submenuContext.isNested ? -6 : 8}
87+
className={cn(
88+
`
89+
90+
`,
91+
className,
92+
)}
93+
{...props}
94+
>
95+
<MenuPrimitive.Popup
96+
className={`
97+
group/popup
98+
relative z-50 py-1.5
99+
100+
min-w-[calc(var(--anchor-width)+25px-var(--spacing)*1.5)]
101+
rounded-lg border bg-gray-100 text-popover-foreground shadow-md
102+
border-gray-300 text-gray-dark
103+
104+
origin-[var(--transform-origin)]
105+
transition-[transform,scale,opacity]
106+
data-[ending-style]:scale-90 data-[ending-style]:opacity-0
107+
data-[starting-style]:scale-90 data-[starting-style]:opacity-0
108+
`}
109+
>
110+
<Arrow />
111+
{children}
112+
</MenuPrimitive.Popup>
113+
</MenuPrimitive.Positioner>
114+
</MenuPrimitive.Portal>
115+
</SubmenuContext.Provider>
116+
);
117+
};
118+
119+
const GroupLabel = ({
120+
className,
121+
...props
122+
}: MenuPrimitive.GroupLabel.Props) => (
123+
<MenuPrimitive.GroupLabel
124+
className={cn(
125+
"px-[25px] text-sm leading-[25px] font-semibold select-none",
126+
className,
127+
)}
128+
{...props}
129+
/>
130+
);
131+
132+
const Item = ({ className, children, ...props }: MenuPrimitive.Item.Props) => (
133+
<MenuPrimitive.Item
134+
className={cn(
135+
`
136+
relative flex h-8 select-none items-center justify-between rounded-sm
137+
px-4 leading-none
138+
139+
after:absolute
140+
after:inset-y-0
141+
after:inset-x-1.5
142+
after:z-[-1]
143+
data-[highlighted]:after:bg-blue-100
144+
data-[highlighted]:after:rounded-sm
145+
146+
data-[highlighted]:text-blue
147+
data-[highlighted]:outline-none
148+
149+
data-[disabled]:pointer-events-none
150+
data-[disabled]:opacity-50
151+
152+
group/item
153+
`,
154+
className,
155+
)}
156+
{...props}
157+
>
158+
{children}
159+
160+
<ChevronRightIcon className="size-4 hidden group-aria-[haspopup]/item:block" />
161+
</MenuPrimitive.Item>
162+
);
163+
164+
const Separator = ({ className, ...props }: MenuPrimitive.Separator.Props) => (
165+
<MenuPrimitive.Separator
166+
className={cn("m-[5px] h-px bg-gray-300", className)}
167+
{...props}
168+
/>
169+
);
170+
171+
export {
172+
Group,
173+
GroupLabel,
174+
Item,
175+
Root,
176+
Trigger,
177+
SubmenuRoot,
178+
SubmenuTrigger,
179+
Positioner,
180+
Separator,
181+
};

0 commit comments

Comments
 (0)