-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
9bba06a
commit abca7b6
Showing
6 changed files
with
174 additions
and
301 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.