1
- import { Fragment , useEffect , useState } from 'react'
1
+ import { Fragment , useEffect , useRef , useState } from 'react'
2
+ import type { FocusEventHandler } from 'react'
2
3
import { css } from 'styled-components'
3
-
4
+ import type { FlattenSimpleInterpolation } from 'styled-components'
5
+ import { MenuItem } from '../../atoms/MenuList/MenuItem'
6
+ import { StyledText } from '../../atoms/StyledText'
7
+ import { LegacyStyledText } from '../../atoms/StyledText/LegacyStyledText'
8
+ import { Tooltip } from '../../atoms/Tooltip'
4
9
import { BORDERS , COLORS } from '../../helix-design-system'
10
+ import { Icon } from '../../icons'
11
+ import { useOnClickOutside } from '../../interaction-enhancers'
12
+ import { Flex } from '../../primitives'
5
13
import {
6
14
ALIGN_CENTER ,
7
15
CURSOR_DEFAULT ,
@@ -14,36 +22,32 @@ import {
14
22
POSITION_ABSOLUTE ,
15
23
POSITION_RELATIVE ,
16
24
} from '../../styles'
17
- import { SPACING , TYPOGRAPHY } from '../../ui-style-constants'
18
- import { Flex } from '../../primitives'
19
- import { Icon } from '../../icons'
20
25
import { useHoverTooltip } from '../../tooltips'
21
- import { useOnClickOutside } from '../../interaction-enhancers'
22
- import { LegacyStyledText } from '../../atoms/StyledText/LegacyStyledText'
23
- import { MenuItem } from '../../atoms/MenuList/MenuItem'
24
- import { Tooltip } from '../../atoms/Tooltip'
25
- import { StyledText } from '../../atoms/StyledText'
26
- import { LiquidIcon } from '../LiquidIcon'
26
+ import { SPACING , TYPOGRAPHY } from '../../ui-style-constants'
27
27
import { DeckInfoLabel } from '../DeckInfoLabel'
28
-
29
- import type { FocusEventHandler } from 'react'
30
- import type { FlattenSimpleInterpolation } from 'styled-components'
28
+ import { LiquidIcon } from '../LiquidIcon'
31
29
32
30
export interface DropdownOption {
31
+ /** dropdown option name */
33
32
name : string
33
+ /** dropdown option value */
34
34
value : string
35
35
/** optional dropdown option for adding the liquid color icon */
36
36
liquidColor ?: string
37
37
/** optional dropdown option for adding the deck label */
38
38
deckLabel ?: string
39
39
/** subtext below the name */
40
40
subtext ?: string
41
+ /** optional disabled */
41
42
disabled ?: boolean
43
+ /** optional tooltip text */
42
44
tooltipText ?: string | null
43
45
}
44
46
45
47
export type DropdownBorder = 'rounded' | 'neutral'
46
48
49
+ type MenuPlacement = 'auto' | 'top' | 'bottom'
50
+
47
51
export interface DropdownMenuProps {
48
52
/** dropdown options */
49
53
filterOptions : DropdownOption [ ]
@@ -72,8 +76,10 @@ export interface DropdownMenuProps {
72
76
/** optional disabled */
73
77
disabled ?: boolean
74
78
/** optional placement of the menu */
75
- menuPlacement ?: 'auto' | 'top' | 'bottom'
79
+ menuPlacement ?: MenuPlacement
80
+ /** optional enter handler */
76
81
onEnter ?: ( id : string ) => void
82
+ /** optional exit handler */
77
83
onExit ?: ( ) => void
78
84
}
79
85
@@ -104,63 +110,97 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
104
110
placement : 'top-end' ,
105
111
} )
106
112
107
- const [ dropdownPosition , setDropdownPosition ] = useState < 'top' | 'bottom' > (
108
- 'bottom'
109
- )
113
+ const [ dropdownPosition , setDropdownPosition ] = useState <
114
+ Omit < MenuPlacement , 'auto' >
115
+ > ( 'bottom' )
110
116
const dropDownMenuWrapperRef = useOnClickOutside < HTMLDivElement > ( {
111
117
onClickOutside : ( ) => {
112
118
setShowDropdownMenu ( false )
113
119
} ,
114
120
} )
115
121
122
+ const menuItemsContainerRef = useRef < HTMLDivElement | null > ( null )
123
+
116
124
useEffect ( ( ) => {
117
- if ( menuPlacement !== 'auto' ) {
118
- setDropdownPosition ( menuPlacement )
125
+ if (
126
+ ! dropDownMenuWrapperRef . current ||
127
+ ! showDropdownMenu ||
128
+ ! menuItemsContainerRef . current ||
129
+ menuPlacement !== 'auto'
130
+ ) {
119
131
return
120
132
}
121
133
122
- const handlePositionCalculation = ( ) : void => {
123
- const dropdownRect = dropDownMenuWrapperRef . current ?. getBoundingClientRect ( )
124
- if ( ! dropdownRect ) return
125
-
126
- const parentElement = dropDownMenuWrapperRef . current ?. parentElement
127
- const grandParentElement = parentElement ?. parentElement ?. parentElement
134
+ const dropdownRect = dropDownMenuWrapperRef . current . getBoundingClientRect ( )
135
+ let potentialMenuHeight = 0
136
+ if ( menuItemsContainerRef . current ) {
137
+ potentialMenuHeight = menuItemsContainerRef . current . scrollHeight
138
+ }
128
139
129
- let availableHeight = window . innerHeight
130
- let scrollOffset = 0
140
+ const viewportHeight = window . innerHeight
141
+ const spaceAbove = dropdownRect . top
142
+ const spaceBelow = viewportHeight - dropdownRect . bottom
131
143
132
- if ( grandParentElement ) {
133
- const grandParentRect = grandParentElement . getBoundingClientRect ( )
134
- availableHeight = grandParentRect . bottom - grandParentRect . top
135
- scrollOffset = grandParentRect . top
136
- } else if ( parentElement ) {
137
- const parentRect = parentElement . getBoundingClientRect ( )
138
- availableHeight = parentRect . bottom - parentRect . top
139
- scrollOffset = parentRect . top
140
- }
144
+ let newPosition : 'top' | 'bottom' = 'bottom'
141
145
142
- const dropdownHeight = filterOptions . length * 34 + 10 // note (kk:2024/12/06) need to modify the value since design uses different height in desktop and pd
143
- const dropdownBottom = dropdownRect . bottom + dropdownHeight - scrollOffset
146
+ if ( menuPlacement === 'auto' ) {
147
+ const fitsBelow = spaceBelow >= potentialMenuHeight
148
+ const fitsAbove = spaceAbove >= potentialMenuHeight
144
149
145
- const fitsBelow = dropdownBottom <= availableHeight
146
- const fitsAbove = dropdownRect . top - dropdownHeight >= scrollOffset
150
+ if ( fitsBelow ) {
151
+ newPosition = 'bottom'
152
+ } else if ( fitsAbove ) {
153
+ newPosition = 'top'
154
+ } else {
155
+ newPosition = spaceBelow >= spaceAbove ? 'bottom' : 'top'
156
+ }
157
+ } else {
158
+ newPosition = menuPlacement
159
+ }
160
+ setDropdownPosition ( newPosition )
147
161
162
+ const handlePositionCalculation = ( ) : void => {
163
+ if (
164
+ ! dropDownMenuWrapperRef . current ||
165
+ ! showDropdownMenu ||
166
+ ! menuItemsContainerRef . current
167
+ )
168
+ return
169
+ const currentTriggerRect = dropDownMenuWrapperRef . current . getBoundingClientRect ( )
170
+ const currentMenuHeight = menuItemsContainerRef . current . scrollHeight
171
+ const currentViewportHeight = window . innerHeight
172
+ const currentSpaceAbove = currentTriggerRect . top
173
+ const currentSpaceBelow =
174
+ currentViewportHeight - currentTriggerRect . bottom
175
+ let determinedPosition = 'bottom'
148
176
if ( menuPlacement === 'auto' ) {
149
- setDropdownPosition ( fitsBelow ? 'bottom' : fitsAbove ? 'top' : 'bottom' )
177
+ const currentFitsBelow = currentSpaceBelow >= currentMenuHeight
178
+ const currentFitsAbove = currentSpaceAbove >= currentMenuHeight
179
+ if ( currentFitsBelow ) determinedPosition = 'bottom'
180
+ else if ( currentFitsAbove ) determinedPosition = 'top'
181
+ else
182
+ determinedPosition =
183
+ currentSpaceBelow >= currentSpaceAbove ? 'bottom' : 'top'
150
184
} else {
151
- setDropdownPosition ( menuPlacement )
185
+ determinedPosition = menuPlacement
152
186
}
187
+ setDropdownPosition ( determinedPosition as 'top' | 'bottom' )
153
188
}
154
189
155
190
window . addEventListener ( 'resize' , handlePositionCalculation )
156
- window . addEventListener ( 'scroll' , handlePositionCalculation )
157
- handlePositionCalculation ( )
191
+ window . addEventListener ( 'scroll' , handlePositionCalculation , true )
158
192
159
193
return ( ) => {
160
194
window . removeEventListener ( 'resize' , handlePositionCalculation )
161
- window . removeEventListener ( 'scroll' , handlePositionCalculation )
195
+ window . removeEventListener ( 'scroll' , handlePositionCalculation , true )
162
196
}
163
- } , [ filterOptions . length , dropDownMenuWrapperRef ] )
197
+ } , [
198
+ showDropdownMenu ,
199
+ filterOptions . length ,
200
+ menuPlacement ,
201
+ dropDownMenuWrapperRef ,
202
+ menuItemsContainerRef ,
203
+ ] )
164
204
165
205
const toggleSetShowDropdownMenu = ( ) : void => {
166
206
if ( ! isDisabled ) {
@@ -275,25 +315,17 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
275
315
</ StyledText >
276
316
</ Flex >
277
317
</ Flex >
278
- { showDropdownMenu ? (
279
- < Icon size = "0.75rem" name = "menu-down" transform = "rotate(180deg)" />
280
- ) : (
281
- < Icon size = "0.75rem" name = "menu-down" />
282
- ) }
318
+ < Icon
319
+ size = "0.75rem"
320
+ name = "menu-down"
321
+ transform = { showDropdownMenu ? 'rotate(180deg)' : undefined }
322
+ />
283
323
</ Flex >
284
324
{ showDropdownMenu && (
285
325
< Flex
286
- zIndex = { 3 }
287
- borderRadius = { BORDERS . borderRadius8 }
288
- boxShadow = { BORDERS . tinyDropShadow }
289
- position = { POSITION_ABSOLUTE }
290
- backgroundColor = { COLORS . white }
291
- flexDirection = { DIRECTION_COLUMN }
292
- width = { width }
293
- top = { dropdownPosition === 'bottom' ? '2.5rem' : undefined }
294
- bottom = { dropdownPosition === 'top' ? '2.5rem' : undefined }
295
- overflowY = { OVERFLOW_AUTO }
296
- maxHeight = "20rem" // Set the maximum display number to 10.
326
+ ref = { menuItemsContainerRef }
327
+ css = { MENU_ITEM_CONTAINER_STYLE ( width , dropdownPosition ) }
328
+ role = "listbox"
297
329
>
298
330
{ filterOptions . map ( ( option , index ) => (
299
331
< Fragment key = { `${ option . name } -${ index } ` } >
@@ -367,6 +399,23 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
367
399
)
368
400
}
369
401
402
+ const MENU_ITEM_CONTAINER_STYLE = (
403
+ width : string ,
404
+ dropdownPosition : Omit < MenuPlacement , 'auto' >
405
+ ) : FlattenSimpleInterpolation => css `
406
+ position : ${ POSITION_ABSOLUTE } ;
407
+ z-index : 3 ;
408
+ width : ${ width } ;
409
+ flex-direction : ${ DIRECTION_COLUMN } ;
410
+ border-radius : ${ BORDERS . borderRadius8 } ;
411
+ background-color : ${ COLORS . white } ;
412
+ box-shadow : ${ BORDERS . tinyDropShadow } ;
413
+ top : ${ dropdownPosition === 'bottom' ? '2.5rem' : undefined } ;
414
+ bottom : ${ dropdownPosition === 'top' ? '2.5rem' : undefined } ;
415
+ overflow-y : ${ OVERFLOW_AUTO } ;
416
+ max-height : 20rem ;
417
+ `
418
+
370
419
export const LINE_CLAMP_TEXT_STYLE = (
371
420
lineClamp ?: number ,
372
421
wordBreak ?: boolean
0 commit comments