Skip to content

Commit abca7b6

Browse files
feat(c): dropdown using headless ui
1 parent 9bba06a commit abca7b6

File tree

6 files changed

+174
-301
lines changed

6 files changed

+174
-301
lines changed

bun.lockb

4.43 KB
Binary file not shown.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@codemirror/lang-javascript": "^6.2.2",
1919
"@codemirror/lang-json": "^6.0.1",
2020
"@codemirror/theme-one-dark": "^6.1.2",
21+
"@headlessui/react": "^2.2.0",
2122
"@monaco-editor/react": "^4.6.0",
2223
"@uiw/react-codemirror": "^4.23.6",
2324
"class-variance-authority": "^0.7.1",
Lines changed: 74 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,100 @@
11
import * as React from "react";
2-
import { createPortal } from "react-dom";
2+
import { Menu } from "@headlessui/react";
33
import { cn } from "../../utils/cn";
44
import { ZINDEX } from "../../utils/z-index";
55

66
export interface DropdownProps {
77
trigger: React.ReactNode;
88
children: React.ReactNode;
99
align?: "left" | "right";
10-
direction?: "down" | "up";
11-
variant?: "default" | "nav";
1210
className?: string;
1311
}
1412

1513
export const Dropdown: React.FC<DropdownProps> = ({
1614
trigger,
1715
children,
1816
align = "left",
19-
direction = "down",
20-
variant = "default",
2117
className,
2218
}) => {
23-
const [isOpen, setIsOpen] = React.useState(false);
24-
const [position, setPosition] = React.useState<{ top?: number; bottom?: number; left: number; right: number }>({ left: 0, right: 0 });
25-
const dropdownRef = React.useRef<HTMLDivElement>(null);
26-
const triggerRef = React.useRef<HTMLDivElement>(null);
27-
28-
React.useEffect(() => {
29-
const handleClickOutside = (event: MouseEvent) => {
30-
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
31-
setIsOpen(false);
32-
}
33-
};
34-
35-
const handleEscape = (event: KeyboardEvent) => {
36-
if (event.key === 'Escape') {
37-
setIsOpen(false);
38-
}
39-
};
40-
41-
document.addEventListener("mousedown", handleClickOutside);
42-
if (variant === "nav") {
43-
document.addEventListener("keydown", handleEscape);
44-
}
45-
46-
return () => {
47-
document.removeEventListener("mousedown", handleClickOutside);
48-
if (variant === "nav") {
49-
document.removeEventListener("keydown", handleEscape);
50-
}
51-
};
52-
}, [variant]);
53-
54-
// Update position when scrolling or resizing
55-
React.useEffect(() => {
56-
if (!isOpen) return;
57-
58-
const updatePosition = () => {
59-
if (!triggerRef.current) return;
60-
const rect = triggerRef.current.getBoundingClientRect();
61-
62-
setPosition({
63-
left: rect.left,
64-
right: window.innerWidth - rect.right,
65-
...(direction === 'down'
66-
? { top: rect.bottom + 8 }
67-
: { bottom: window.innerHeight - rect.top + 8 }
68-
),
69-
});
70-
};
71-
72-
updatePosition();
73-
window.addEventListener('scroll', updatePosition, true);
74-
window.addEventListener('resize', updatePosition);
75-
76-
return () => {
77-
window.removeEventListener('scroll', updatePosition, true);
78-
window.removeEventListener('resize', updatePosition);
79-
};
80-
}, [isOpen, direction]);
81-
82-
const triggerClasses = cn({
83-
"cursor-pointer": true,
84-
"flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 transition-colors":
85-
variant === "nav",
86-
});
87-
88-
const menuClasses = cn(
89-
"absolute rounded-lg shadow-lg min-w-[8rem] py-1",
90-
variant === "default" && [
91-
"bg-white dark:bg-gray-900",
92-
"border border-gray-200 dark:border-gray-800",
93-
],
94-
variant === "nav" && [
95-
"bg-white dark:bg-gray-800",
96-
"border border-gray-200 dark:border-gray-700",
97-
"w-48 rounded-md",
98-
],
99-
{
100-
"left-0": align === "left",
101-
"right-0": align === "right",
102-
"top-full mt-2": direction === "down",
103-
"bottom-full mb-2": direction === "up",
104-
},
105-
className
106-
);
107-
10819
return (
109-
<div className="relative inline-block" ref={dropdownRef}>
110-
<div
111-
ref={triggerRef}
112-
onClick={() => setIsOpen(!isOpen)}
113-
className={triggerClasses}
114-
>
20+
<Menu as="div" className="relative inline-block text-left">
21+
<Menu.Button className="inline-flex cursor-pointer">
11522
{trigger}
116-
</div>
117-
{isOpen && createPortal(
118-
<div
119-
className={menuClasses}
120-
style={{
121-
position: 'fixed',
122-
...position,
123-
zIndex: ZINDEX.dropdown,
124-
}}
125-
>
126-
{children}
127-
</div>,
128-
document.body
129-
)}
130-
</div>
23+
</Menu.Button>
24+
25+
<Menu.Items
26+
className={cn(
27+
"absolute mt-2 rounded-lg shadow-lg",
28+
"bg-white dark:bg-gray-800",
29+
"border border-gray-200 dark:border-gray-700",
30+
"focus:outline-none",
31+
"min-w-[8rem] py-1",
32+
{
33+
"left-0": align === "left",
34+
"right-0": align === "right",
35+
},
36+
className
37+
)}
38+
style={{ zIndex: ZINDEX.dropdown }}
39+
>
40+
{children}
41+
</Menu.Items>
42+
</Menu>
13143
);
13244
};
13345

134-
export interface DropdownItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
135-
icon?: React.ReactNode;
46+
interface DropdownItemProps {
47+
children: React.ReactNode;
48+
href?: string;
49+
onClick?: () => void;
50+
className?: string;
13651
}
13752

138-
export const DropdownItem = React.forwardRef<HTMLButtonElement, DropdownItemProps>(
139-
({ className, children, icon, ...props }, ref) => (
140-
<button
141-
ref={ref}
142-
className={cn(
143-
"flex w-full items-center px-3 py-2 text-sm gap-2",
144-
"text-gray-700 dark:text-gray-200",
145-
"hover:bg-gray-100 dark:hover:bg-gray-800",
146-
"focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800",
147-
className
148-
)}
149-
{...props}
150-
>
151-
{icon}
152-
{children}
153-
</button>
154-
)
53+
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
54+
({ children, href, onClick, className }, ref) => {
55+
const content = (
56+
<div
57+
ref={ref}
58+
className={cn(
59+
"w-full px-4 py-2 text-sm",
60+
"text-gray-700 dark:text-gray-200",
61+
className
62+
)}
63+
>
64+
{children}
65+
</div>
66+
);
67+
68+
return (
69+
<Menu.Item>
70+
{({ active }) =>
71+
href ? (
72+
<a
73+
href={href}
74+
onClick={onClick}
75+
className={cn(
76+
"block w-full",
77+
active && "bg-gray-100 dark:bg-gray-700"
78+
)}
79+
>
80+
{content}
81+
</a>
82+
) : (
83+
<button
84+
type="button"
85+
onClick={onClick}
86+
className={cn(
87+
"block w-full text-left",
88+
active && "bg-gray-100 dark:bg-gray-700"
89+
)}
90+
>
91+
{content}
92+
</button>
93+
)
94+
}
95+
</Menu.Item>
96+
);
97+
}
15598
);
99+
156100
DropdownItem.displayName = "DropdownItem";

src/components/navigation/NavMenu.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,23 @@ import { cn } from "../../utils/cn";
33
import { ChevronDown } from "lucide-react";
44
import { Dropdown, DropdownProps } from "../dropdown/Dropdown";
55

6-
export interface NavMenuProps extends Omit<DropdownProps, 'variant' | 'trigger'> {
6+
export interface NavMenuProps extends Omit<DropdownProps, 'trigger'> {
77
trigger: React.ReactNode;
88
}
99

1010
export const NavMenu: React.FC<NavMenuProps> = ({
1111
trigger,
1212
children,
13-
direction = "down",
1413
...props
1514
}) => {
1615
return (
1716
<Dropdown
1817
trigger={
19-
<>
18+
<div className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 transition-colors">
2019
{trigger}
21-
<ChevronDown
22-
className={cn(
23-
"h-4 w-4 transition-transform duration-200",
24-
direction === "up" && "rotate-180"
25-
)}
26-
/>
27-
</>
20+
<ChevronDown className="h-4 w-4" />
21+
</div>
2822
}
29-
variant="nav"
30-
direction={direction}
3123
{...props}
3224
>
3325
{children}
@@ -52,4 +44,5 @@ export const NavMenuItem = React.forwardRef<
5244
{children}
5345
</button>
5446
));
47+
5548
NavMenuItem.displayName = "NavMenuItem";

0 commit comments

Comments
 (0)