Skip to content

Commit

Permalink
feat(c): dropdown using headless ui
Browse files Browse the repository at this point in the history
  • Loading branch information
brianorwhatever committed Jan 30, 2025
1 parent 9bba06a commit abca7b6
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 301 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@headlessui/react": "^2.2.0",
"@monaco-editor/react": "^4.6.0",
"@uiw/react-codemirror": "^4.23.6",
"class-variance-authority": "^0.7.1",
Expand Down
204 changes: 74 additions & 130 deletions src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,156 +1,100 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { Menu } from "@headlessui/react";
import { cn } from "../../utils/cn";
import { ZINDEX } from "../../utils/z-index";

export interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
align?: "left" | "right";
direction?: "down" | "up";
variant?: "default" | "nav";
className?: string;
}

export const Dropdown: React.FC<DropdownProps> = ({
trigger,
children,
align = "left",
direction = "down",
variant = "default",
className,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const [position, setPosition] = React.useState<{ top?: number; bottom?: number; left: number; right: number }>({ left: 0, right: 0 });
const dropdownRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
if (variant === "nav") {
document.addEventListener("keydown", handleEscape);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
if (variant === "nav") {
document.removeEventListener("keydown", handleEscape);
}
};
}, [variant]);

// Update position when scrolling or resizing
React.useEffect(() => {
if (!isOpen) return;

const updatePosition = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();

setPosition({
left: rect.left,
right: window.innerWidth - rect.right,
...(direction === 'down'
? { top: rect.bottom + 8 }
: { bottom: window.innerHeight - rect.top + 8 }
),
});
};

updatePosition();
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);

return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [isOpen, direction]);

const triggerClasses = cn({
"cursor-pointer": true,
"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":
variant === "nav",
});

const menuClasses = cn(
"absolute rounded-lg shadow-lg min-w-[8rem] py-1",
variant === "default" && [
"bg-white dark:bg-gray-900",
"border border-gray-200 dark:border-gray-800",
],
variant === "nav" && [
"bg-white dark:bg-gray-800",
"border border-gray-200 dark:border-gray-700",
"w-48 rounded-md",
],
{
"left-0": align === "left",
"right-0": align === "right",
"top-full mt-2": direction === "down",
"bottom-full mb-2": direction === "up",
},
className
);

return (
<div className="relative inline-block" ref={dropdownRef}>
<div
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
className={triggerClasses}
>
<Menu as="div" className="relative inline-block text-left">
<Menu.Button className="inline-flex cursor-pointer">
{trigger}
</div>
{isOpen && createPortal(
<div
className={menuClasses}
style={{
position: 'fixed',
...position,
zIndex: ZINDEX.dropdown,
}}
>
{children}
</div>,
document.body
)}
</div>
</Menu.Button>

<Menu.Items
className={cn(
"absolute mt-2 rounded-lg shadow-lg",
"bg-white dark:bg-gray-800",
"border border-gray-200 dark:border-gray-700",
"focus:outline-none",
"min-w-[8rem] py-1",
{
"left-0": align === "left",
"right-0": align === "right",
},
className
)}
style={{ zIndex: ZINDEX.dropdown }}
>
{children}
</Menu.Items>
</Menu>
);
};

export interface DropdownItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode;
interface DropdownItemProps {
children: React.ReactNode;
href?: string;
onClick?: () => void;
className?: string;
}

export const DropdownItem = React.forwardRef<HTMLButtonElement, DropdownItemProps>(
({ className, children, icon, ...props }, ref) => (
<button
ref={ref}
className={cn(
"flex w-full items-center px-3 py-2 text-sm gap-2",
"text-gray-700 dark:text-gray-200",
"hover:bg-gray-100 dark:hover:bg-gray-800",
"focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800",
className
)}
{...props}
>
{icon}
{children}
</button>
)
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
({ children, href, onClick, className }, ref) => {
const content = (
<div
ref={ref}
className={cn(
"w-full px-4 py-2 text-sm",
"text-gray-700 dark:text-gray-200",
className
)}
>
{children}
</div>
);

return (
<Menu.Item>
{({ active }) =>
href ? (
<a
href={href}
onClick={onClick}
className={cn(
"block w-full",
active && "bg-gray-100 dark:bg-gray-700"
)}
>
{content}
</a>
) : (
<button
type="button"
onClick={onClick}
className={cn(
"block w-full text-left",
active && "bg-gray-100 dark:bg-gray-700"
)}
>
{content}
</button>
)
}
</Menu.Item>
);
}
);

DropdownItem.displayName = "DropdownItem";
17 changes: 5 additions & 12 deletions src/components/navigation/NavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,23 @@ import { cn } from "../../utils/cn";
import { ChevronDown } from "lucide-react";
import { Dropdown, DropdownProps } from "../dropdown/Dropdown";

export interface NavMenuProps extends Omit<DropdownProps, 'variant' | 'trigger'> {
export interface NavMenuProps extends Omit<DropdownProps, 'trigger'> {
trigger: React.ReactNode;
}

export const NavMenu: React.FC<NavMenuProps> = ({
trigger,
children,
direction = "down",
...props
}) => {
return (
<Dropdown
trigger={
<>
<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">
{trigger}
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
direction === "up" && "rotate-180"
)}
/>
</>
<ChevronDown className="h-4 w-4" />
</div>
}
variant="nav"
direction={direction}
{...props}
>
{children}
Expand All @@ -52,4 +44,5 @@ export const NavMenuItem = React.forwardRef<
{children}
</button>
));

NavMenuItem.displayName = "NavMenuItem";
Loading

0 comments on commit abca7b6

Please sign in to comment.