1- import React , { useState , useRef , useEffect } from 'react' ;
1+ import React , { useState , useRef , useEffect , useCallback } from 'react' ;
2+ import { createPortal } from 'react-dom' ;
23import { Icon } from './Icons' ;
34
45interface DropdownProps {
@@ -22,11 +23,48 @@ export const Dropdown: React.FC<DropdownProps> = ({
2223} ) => {
2324 const [ isOpen , setIsOpen ] = useState ( false ) ;
2425 const [ customColor , setCustomColor ] = useState ( "#000000" ) ;
26+ const [ menuPos , setMenuPos ] = useState ( { top : 0 , left : 0 } ) ;
2527 const dropdownRef = useRef < HTMLDivElement > ( null ) ;
28+ const menuRef = useRef < HTMLDivElement > ( null ) ;
29+ const buttonRef = useRef < HTMLButtonElement > ( null ) ;
30+
31+ const updateMenuPosition = useCallback ( ( ) => {
32+ if ( ! buttonRef . current ) return ;
33+ const rect = buttonRef . current . getBoundingClientRect ( ) ;
34+ const pad = 8 ;
35+ let top = rect . bottom + 4 ;
36+ let left = rect . left ;
37+
38+ const menuEl = menuRef . current ;
39+ if ( menuEl ) {
40+ const menuW = menuEl . offsetWidth ;
41+ const menuH = menuEl . offsetHeight ;
42+ if ( left + menuW > window . innerWidth - pad ) {
43+ left = window . innerWidth - menuW - pad ;
44+ }
45+ if ( left < pad ) left = pad ;
46+ if ( top + menuH > window . innerHeight - pad ) {
47+ top = rect . top - menuH - 4 ;
48+ }
49+ if ( top < pad ) top = pad ;
50+ }
51+
52+ setMenuPos ( { top, left } ) ;
53+ } , [ ] ) ;
54+
55+ useEffect ( ( ) => {
56+ if ( ! isOpen ) return ;
57+ updateMenuPosition ( ) ;
58+ requestAnimationFrame ( updateMenuPosition ) ;
59+ } , [ isOpen , updateMenuPosition ] ) ;
2660
2761 useEffect ( ( ) => {
2862 const handleClickOutside = ( event : MouseEvent ) => {
29- if ( dropdownRef . current && ! dropdownRef . current . contains ( event . target as Node ) ) {
63+ const target = event . target as Node ;
64+ if (
65+ dropdownRef . current && ! dropdownRef . current . contains ( target ) &&
66+ ( ! menuRef . current || ! menuRef . current . contains ( target ) )
67+ ) {
3068 setIsOpen ( false ) ;
3169 }
3270 } ;
@@ -47,7 +85,6 @@ export const Dropdown: React.FC<DropdownProps> = ({
4785
4886 const currentOption = options . find ( opt => opt . value === currentValue ) ;
4987
50- // Close on Escape key
5188 useEffect ( ( ) => {
5289 if ( ! isOpen ) return ;
5390 const handleKeyDown = ( e : KeyboardEvent ) => {
@@ -60,9 +97,104 @@ export const Dropdown: React.FC<DropdownProps> = ({
6097 return ( ) => document . removeEventListener ( "keydown" , handleKeyDown ) ;
6198 } , [ isOpen ] ) ;
6299
100+ const menuContent = isOpen ? (
101+ < div
102+ ref = { menuRef }
103+ className = "rte-dropdown-menu"
104+ role = "listbox"
105+ aria-label = { label }
106+ style = { {
107+ position : 'fixed' ,
108+ top : menuPos . top ,
109+ left : menuPos . left ,
110+ } }
111+ onMouseDown = { ( e ) => e . preventDefault ( ) }
112+ >
113+ { options . map ( ( option ) => (
114+ < button
115+ key = { option . value }
116+ type = "button"
117+ role = "option"
118+ aria-selected = { currentValue === option . value }
119+ className = { `rte-dropdown-item ${ currentValue === option . value ? 'rte-dropdown-item-active' : '' } ` }
120+ onClick = { ( ) => handleSelect ( option . value ) }
121+ >
122+ { option . color && (
123+ < span
124+ className = { `rte-dropdown-color-preview ${ currentValue === option . value ? 'active' : '' } ` }
125+ style = { { backgroundColor : option . color } }
126+ />
127+ ) }
128+ { option . preview && ! option . headingPreview && (
129+ < span
130+ className = "rte-dropdown-fontsize-preview"
131+ style = { { fontSize : `${ option . preview } px` } }
132+ >
133+ Aa
134+ </ span >
135+ ) }
136+ { option . headingPreview && (
137+ < span
138+ className = { `rte-dropdown-heading-preview ${ option . headingPreview } ` }
139+ >
140+ { option . headingPreview === 'p' ? 'Normal' : option . headingPreview . toUpperCase ( ) }
141+ </ span >
142+ ) }
143+ { option . icon && < Icon icon = { option . icon } width = { 16 } height = { 16 } /> }
144+ < span style = { { flex : 1 , fontWeight : currentValue === option . value ? 600 : 400 } } >
145+ { option . label }
146+ </ span >
147+ </ button >
148+ ) ) }
149+ { showCustomColorInput && (
150+ < div
151+ className = "rte-color-custom-input"
152+ onMouseDown = { ( e ) => e . stopPropagation ( ) }
153+ >
154+ < input
155+ type = "color"
156+ value = { customColor }
157+ onChange = { ( e ) => setCustomColor ( e . target . value ) }
158+ title = "Pick a color"
159+ />
160+ < input
161+ type = "text"
162+ value = { customColor }
163+ onChange = { ( e ) => {
164+ const v = e . target . value ;
165+ setCustomColor ( v ) ;
166+ } }
167+ placeholder = "#000000"
168+ maxLength = { 7 }
169+ onKeyDown = { ( e ) => {
170+ if ( e . key === "Enter" ) {
171+ e . preventDefault ( ) ;
172+ if ( / ^ # [ 0 - 9 a - f A - F ] { 3 , 6 } $ / . test ( customColor ) ) {
173+ handleSelect ( customColor ) ;
174+ }
175+ }
176+ } }
177+ />
178+ < button
179+ type = "button"
180+ className = "rte-color-custom-apply"
181+ onClick = { ( ) => {
182+ if ( / ^ # [ 0 - 9 a - f A - F ] { 3 , 6 } $ / . test ( customColor ) ) {
183+ handleSelect ( customColor ) ;
184+ }
185+ } }
186+ >
187+ Apply
188+ </ button >
189+ </ div >
190+ ) }
191+ </ div >
192+ ) : null ;
193+
63194 return (
64195 < div className = "rte-dropdown" ref = { dropdownRef } onMouseDown = { ( e ) => e . preventDefault ( ) } >
65196 < button
197+ ref = { buttonRef }
66198 type = "button"
67199 onClick = { ( ) => ! disabled && setIsOpen ( ! isOpen ) }
68200 disabled = { disabled }
@@ -77,89 +209,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
77209 < span className = "rte-dropdown-value" > { currentOption . label } </ span >
78210 ) }
79211 </ button >
80- { isOpen && (
81- < div className = "rte-dropdown-menu" role = "listbox" aria-label = { label } >
82- { options . map ( ( option ) => (
83- < button
84- key = { option . value }
85- type = "button"
86- role = "option"
87- aria-selected = { currentValue === option . value }
88- className = { `rte-dropdown-item ${ currentValue === option . value ? 'rte-dropdown-item-active' : '' } ` }
89- onClick = { ( ) => handleSelect ( option . value ) }
90- >
91- { option . color && (
92- < span
93- className = { `rte-dropdown-color-preview ${ currentValue === option . value ? 'active' : '' } ` }
94- style = { { backgroundColor : option . color } }
95- />
96- ) }
97- { option . preview && ! option . headingPreview && (
98- < span
99- className = "rte-dropdown-fontsize-preview"
100- style = { { fontSize : `${ option . preview } px` } }
101- >
102- Aa
103- </ span >
104- ) }
105- { option . headingPreview && (
106- < span
107- className = { `rte-dropdown-heading-preview ${ option . headingPreview } ` }
108- >
109- { option . headingPreview === 'p' ? 'Normal' : option . headingPreview . toUpperCase ( ) }
110- </ span >
111- ) }
112- { option . icon && < Icon icon = { option . icon } width = { 16 } height = { 16 } /> }
113- < span style = { { flex : 1 , fontWeight : currentValue === option . value ? 600 : 400 } } >
114- { option . label }
115- </ span >
116- </ button >
117- ) ) }
118- { showCustomColorInput && (
119- < div
120- className = "rte-color-custom-input"
121- onMouseDown = { ( e ) => e . stopPropagation ( ) }
122- >
123- < input
124- type = "color"
125- value = { customColor }
126- onChange = { ( e ) => setCustomColor ( e . target . value ) }
127- title = "Pick a color"
128- />
129- < input
130- type = "text"
131- value = { customColor }
132- onChange = { ( e ) => {
133- const v = e . target . value ;
134- setCustomColor ( v ) ;
135- } }
136- placeholder = "#000000"
137- maxLength = { 7 }
138- onKeyDown = { ( e ) => {
139- if ( e . key === "Enter" ) {
140- e . preventDefault ( ) ;
141- if ( / ^ # [ 0 - 9 a - f A - F ] { 3 , 6 } $ / . test ( customColor ) ) {
142- handleSelect ( customColor ) ;
143- }
144- }
145- } }
146- />
147- < button
148- type = "button"
149- className = "rte-color-custom-apply"
150- onClick = { ( ) => {
151- if ( / ^ # [ 0 - 9 a - f A - F ] { 3 , 6 } $ / . test ( customColor ) ) {
152- handleSelect ( customColor ) ;
153- }
154- } }
155- >
156- Apply
157- </ button >
158- </ div >
159- ) }
160- </ div >
161- ) }
212+ { menuContent && createPortal ( menuContent , document . body ) }
162213 </ div >
163214 ) ;
164215} ;
165-
0 commit comments