Skip to content

Commit bf4de79

Browse files
authored
fix(components): fix dropdown menu menuplacement issue (#18291)
* fix(components): fix dropdown menu menuplacement issue
1 parent 8eadaf2 commit bf4de79

File tree

2 files changed

+113
-64
lines changed

2 files changed

+113
-64
lines changed

components/src/molecules/DropdownMenu/DropdownMenu.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function createMockOptions(): DropdownOption[] {
1414
const mockOptions: DropdownOption[] = createMockOptions()
1515

1616
const meta: Meta<typeof DropdownMenuComponent> = {
17-
title: 'App/Atoms/DropdownMenu',
17+
title: 'Helix/Molecules/DropdownMenu',
1818
component: DropdownMenuComponent,
1919
}
2020
export default meta

components/src/molecules/DropdownMenu/index.tsx

Lines changed: 112 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
import { Fragment, useEffect, useState } from 'react'
1+
import { Fragment, useEffect, useRef, useState } from 'react'
2+
import type { FocusEventHandler } from 'react'
23
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'
49
import { BORDERS, COLORS } from '../../helix-design-system'
10+
import { Icon } from '../../icons'
11+
import { useOnClickOutside } from '../../interaction-enhancers'
12+
import { Flex } from '../../primitives'
513
import {
614
ALIGN_CENTER,
715
CURSOR_DEFAULT,
@@ -14,36 +22,32 @@ import {
1422
POSITION_ABSOLUTE,
1523
POSITION_RELATIVE,
1624
} from '../../styles'
17-
import { SPACING, TYPOGRAPHY } from '../../ui-style-constants'
18-
import { Flex } from '../../primitives'
19-
import { Icon } from '../../icons'
2025
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'
2727
import { DeckInfoLabel } from '../DeckInfoLabel'
28-
29-
import type { FocusEventHandler } from 'react'
30-
import type { FlattenSimpleInterpolation } from 'styled-components'
28+
import { LiquidIcon } from '../LiquidIcon'
3129

3230
export interface DropdownOption {
31+
/** dropdown option name */
3332
name: string
33+
/** dropdown option value */
3434
value: string
3535
/** optional dropdown option for adding the liquid color icon */
3636
liquidColor?: string
3737
/** optional dropdown option for adding the deck label */
3838
deckLabel?: string
3939
/** subtext below the name */
4040
subtext?: string
41+
/** optional disabled */
4142
disabled?: boolean
43+
/** optional tooltip text */
4244
tooltipText?: string | null
4345
}
4446

4547
export type DropdownBorder = 'rounded' | 'neutral'
4648

49+
type MenuPlacement = 'auto' | 'top' | 'bottom'
50+
4751
export interface DropdownMenuProps {
4852
/** dropdown options */
4953
filterOptions: DropdownOption[]
@@ -72,8 +76,10 @@ export interface DropdownMenuProps {
7276
/** optional disabled */
7377
disabled?: boolean
7478
/** optional placement of the menu */
75-
menuPlacement?: 'auto' | 'top' | 'bottom'
79+
menuPlacement?: MenuPlacement
80+
/** optional enter handler */
7681
onEnter?: (id: string) => void
82+
/** optional exit handler */
7783
onExit?: () => void
7884
}
7985

@@ -104,63 +110,97 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
104110
placement: 'top-end',
105111
})
106112

107-
const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>(
108-
'bottom'
109-
)
113+
const [dropdownPosition, setDropdownPosition] = useState<
114+
Omit<MenuPlacement, 'auto'>
115+
>('bottom')
110116
const dropDownMenuWrapperRef = useOnClickOutside<HTMLDivElement>({
111117
onClickOutside: () => {
112118
setShowDropdownMenu(false)
113119
},
114120
})
115121

122+
const menuItemsContainerRef = useRef<HTMLDivElement | null>(null)
123+
116124
useEffect(() => {
117-
if (menuPlacement !== 'auto') {
118-
setDropdownPosition(menuPlacement)
125+
if (
126+
!dropDownMenuWrapperRef.current ||
127+
!showDropdownMenu ||
128+
!menuItemsContainerRef.current ||
129+
menuPlacement !== 'auto'
130+
) {
119131
return
120132
}
121133

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+
}
128139

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
131143

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'
141145

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
144149

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)
147161

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'
148176
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'
150184
} else {
151-
setDropdownPosition(menuPlacement)
185+
determinedPosition = menuPlacement
152186
}
187+
setDropdownPosition(determinedPosition as 'top' | 'bottom')
153188
}
154189

155190
window.addEventListener('resize', handlePositionCalculation)
156-
window.addEventListener('scroll', handlePositionCalculation)
157-
handlePositionCalculation()
191+
window.addEventListener('scroll', handlePositionCalculation, true)
158192

159193
return () => {
160194
window.removeEventListener('resize', handlePositionCalculation)
161-
window.removeEventListener('scroll', handlePositionCalculation)
195+
window.removeEventListener('scroll', handlePositionCalculation, true)
162196
}
163-
}, [filterOptions.length, dropDownMenuWrapperRef])
197+
}, [
198+
showDropdownMenu,
199+
filterOptions.length,
200+
menuPlacement,
201+
dropDownMenuWrapperRef,
202+
menuItemsContainerRef,
203+
])
164204

165205
const toggleSetShowDropdownMenu = (): void => {
166206
if (!isDisabled) {
@@ -275,25 +315,17 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
275315
</StyledText>
276316
</Flex>
277317
</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+
/>
283323
</Flex>
284324
{showDropdownMenu && (
285325
<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"
297329
>
298330
{filterOptions.map((option, index) => (
299331
<Fragment key={`${option.name}-${index}`}>
@@ -367,6 +399,23 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
367399
)
368400
}
369401

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+
370419
export const LINE_CLAMP_TEXT_STYLE = (
371420
lineClamp?: number,
372421
wordBreak?: boolean

0 commit comments

Comments
 (0)