11import * as React from "react" ;
2+ import { createPortal } from "react-dom" ;
23import { cn } from "../../utils/cn" ;
34import { ZINDEX } from "../../utils/z-index" ;
45
56export interface DropdownProps {
67 trigger : React . ReactNode ;
78 children : React . ReactNode ;
89 align ?: "left" | "right" ;
10+ direction ?: "down" | "up" ;
11+ variant ?: "default" | "nav" ;
912 className ?: string ;
1013}
1114
1215export const Dropdown : React . FC < DropdownProps > = ( {
1316 trigger,
1417 children,
1518 align = "left" ,
19+ direction = "down" ,
20+ variant = "default" ,
1621 className,
1722} ) => {
1823 const [ isOpen , setIsOpen ] = React . useState ( false ) ;
1924 const dropdownRef = React . useRef < HTMLDivElement > ( null ) ;
25+ const triggerRef = React . useRef < HTMLDivElement > ( null ) ;
2026
2127 React . useEffect ( ( ) => {
2228 const handleClickOutside = ( event : MouseEvent ) => {
@@ -25,30 +31,87 @@ export const Dropdown: React.FC<DropdownProps> = ({
2531 }
2632 } ;
2733
34+ const handleEscape = ( event : KeyboardEvent ) => {
35+ if ( event . key === 'Escape' ) {
36+ setIsOpen ( false ) ;
37+ }
38+ } ;
39+
2840 document . addEventListener ( "mousedown" , handleClickOutside ) ;
29- return ( ) => document . removeEventListener ( "mousedown" , handleClickOutside ) ;
30- } , [ ] ) ;
41+ if ( variant === "nav" ) {
42+ document . addEventListener ( "keydown" , handleEscape ) ;
43+ }
44+
45+ return ( ) => {
46+ document . removeEventListener ( "mousedown" , handleClickOutside ) ;
47+ if ( variant === "nav" ) {
48+ document . removeEventListener ( "keydown" , handleEscape ) ;
49+ }
50+ } ;
51+ } , [ variant ] ) ;
52+
53+ const triggerClasses = cn ( {
54+ "cursor-pointer" : true ,
55+ "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" :
56+ variant === "nav" ,
57+ } ) ;
58+
59+ const menuClasses = cn (
60+ "absolute rounded-lg shadow-lg min-w-[8rem] py-1" ,
61+ variant === "default" && [
62+ "bg-white dark:bg-gray-900" ,
63+ "border border-gray-200 dark:border-gray-800" ,
64+ ] ,
65+ variant === "nav" && [
66+ "bg-white dark:bg-gray-800" ,
67+ "border border-gray-200 dark:border-gray-700" ,
68+ "w-48 rounded-md" ,
69+ ] ,
70+ {
71+ "left-0" : align === "left" ,
72+ "right-0" : align === "right" ,
73+ "top-full mt-2" : direction === "down" ,
74+ "bottom-full mb-2" : direction === "up" ,
75+ } ,
76+ className
77+ ) ;
78+
79+ // Get position for the dropdown menu
80+ const getDropdownPosition = ( ) => {
81+ if ( ! triggerRef . current ) return { } ;
82+ const rect = triggerRef . current . getBoundingClientRect ( ) ;
83+
84+ return {
85+ position : 'fixed' as const ,
86+ left : rect . left ,
87+ right : window . innerWidth - rect . right ,
88+ ...( direction === 'down'
89+ ? { top : rect . bottom + 8 }
90+ : { bottom : window . innerHeight - rect . top + 8 }
91+ ) ,
92+ } ;
93+ } ;
3194
3295 return (
3396 < div className = "relative inline-block" ref = { dropdownRef } >
34- < div onClick = { ( ) => setIsOpen ( ! isOpen ) } > { trigger } </ div >
35- { isOpen && (
97+ < div
98+ ref = { triggerRef }
99+ onClick = { ( ) => setIsOpen ( ! isOpen ) }
100+ className = { triggerClasses }
101+ >
102+ { trigger }
103+ </ div >
104+ { isOpen && createPortal (
36105 < div
37- className = { cn (
38- "absolute mt-2 rounded-lg shadow-lg" ,
39- "bg-white dark:bg-gray-900" ,
40- "border border-gray-200 dark:border-gray-800" ,
41- "min-w-[8rem] py-1" ,
42- {
43- "left-0" : align === "left" ,
44- "right-0" : align === "right" ,
45- } ,
46- className
47- ) }
48- style = { { zIndex : ZINDEX . dropdown } }
106+ className = { menuClasses }
107+ style = { {
108+ ...getDropdownPosition ( ) ,
109+ zIndex : ZINDEX . dropdown ,
110+ } }
49111 >
50112 { children }
51- </ div >
113+ </ div > ,
114+ document . body
52115 ) }
53116 </ div >
54117 ) ;
0 commit comments