Skip to content

Commit 44c83e7

Browse files
committed
fix(frontend): portal Select dropdown to escape modal overflow clipping
Render the dropdown via createPortal with position: fixed so it isn't clipped by ancestor overflow containers (e.g. the test connection modal's scrollable body). Reposition on resize/scroll and auto-flip when viewport space below is insufficient.
1 parent 4bd3c03 commit 44c83e7

1 file changed

Lines changed: 105 additions & 41 deletions

File tree

Lines changed: 105 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Check, ChevronDown } from 'lucide-react'
2-
import { useEffect, useRef, useState } from 'react'
2+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
3+
import { createPortal } from 'react-dom'
34
import { cn } from '@/lib/utils'
45

56
export interface SelectOption {
@@ -17,6 +18,18 @@ interface SelectProps {
1718
compact?: boolean
1819
}
1920

21+
interface DropdownPosition {
22+
top: number
23+
left: number
24+
width: number
25+
maxHeight: number
26+
openUp: boolean
27+
}
28+
29+
const DROPDOWN_GAP = 8
30+
const DROPDOWN_MAX_HEIGHT = 288
31+
const VIEWPORT_PADDING = 8
32+
2033
export function Select({
2134
value,
2235
onValueChange,
@@ -27,16 +40,42 @@ export function Select({
2740
compact = false,
2841
}: SelectProps) {
2942
const [open, setOpen] = useState(false)
30-
const rootRef = useRef<HTMLDivElement>(null)
43+
const [position, setPosition] = useState<DropdownPosition | null>(null)
44+
const triggerRef = useRef<HTMLButtonElement>(null)
45+
const dropdownRef = useRef<HTMLDivElement>(null)
3146
const selectedOption = options.find((option) => option.value === value)
3247

48+
const computePosition = useCallback(() => {
49+
const trigger = triggerRef.current
50+
if (!trigger) return
51+
const rect = trigger.getBoundingClientRect()
52+
const viewportHeight = window.innerHeight
53+
const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP - VIEWPORT_PADDING
54+
const spaceAbove = rect.top - DROPDOWN_GAP - VIEWPORT_PADDING
55+
const openUp = spaceBelow < Math.min(DROPDOWN_MAX_HEIGHT, 160) && spaceAbove > spaceBelow
56+
const maxHeight = Math.max(120, Math.min(DROPDOWN_MAX_HEIGHT, openUp ? spaceAbove : spaceBelow))
57+
setPosition({
58+
top: openUp ? rect.top - DROPDOWN_GAP : rect.bottom + DROPDOWN_GAP,
59+
left: rect.left,
60+
width: rect.width,
61+
maxHeight,
62+
openUp,
63+
})
64+
}, [])
65+
66+
useLayoutEffect(() => {
67+
if (!open) return
68+
computePosition()
69+
}, [open, computePosition, options.length])
70+
3371
useEffect(() => {
3472
if (!open) return
3573

3674
const handlePointerDown = (event: MouseEvent) => {
37-
if (!rootRef.current?.contains(event.target as Node)) {
38-
setOpen(false)
39-
}
75+
const target = event.target as Node
76+
if (triggerRef.current?.contains(target)) return
77+
if (dropdownRef.current?.contains(target)) return
78+
setOpen(false)
4079
}
4180

4281
const handleEscape = (event: KeyboardEvent) => {
@@ -45,18 +84,25 @@ export function Select({
4584
}
4685
}
4786

87+
const handleReposition = () => computePosition()
88+
4889
document.addEventListener('mousedown', handlePointerDown)
4990
document.addEventListener('keydown', handleEscape)
91+
window.addEventListener('resize', handleReposition)
92+
window.addEventListener('scroll', handleReposition, true)
5093

5194
return () => {
5295
document.removeEventListener('mousedown', handlePointerDown)
5396
document.removeEventListener('keydown', handleEscape)
97+
window.removeEventListener('resize', handleReposition)
98+
window.removeEventListener('scroll', handleReposition, true)
5499
}
55-
}, [open])
100+
}, [open, computePosition])
56101

57102
return (
58-
<div ref={rootRef} className={cn('relative w-full', className)}>
103+
<div className={cn('relative w-full', className)}>
59104
<button
105+
ref={triggerRef}
60106
type="button"
61107
disabled={disabled}
62108
aria-haspopup="listbox"
@@ -81,40 +127,58 @@ export function Select({
81127
<ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-180')} />
82128
</button>
83129

84-
{open ? (
85-
<div className={cn('absolute top-[calc(100%+0.5rem)] left-0 z-50 overflow-hidden border border-border bg-popover shadow-[0_18px_40px_hsl(222_30%_18%/0.12)] backdrop-blur-sm', compact ? 'min-w-full rounded-lg' : 'right-0 rounded-lg')}>
86-
<div className={cn('max-h-72 overflow-auto', compact ? 'p-1' : 'p-2')}>
87-
<div role="listbox" aria-activedescendant={value || undefined} className={compact ? 'space-y-0.5' : 'space-y-1'}>
88-
{options.map((option) => {
89-
const isSelected = option.value === value
90-
return (
91-
<button
92-
key={option.value}
93-
id={option.value}
94-
type="button"
95-
role="option"
96-
aria-selected={isSelected}
97-
className={cn(
98-
'flex w-full items-center justify-between gap-3 text-left transition-colors',
99-
compact ? 'rounded-md px-2 py-1.5 text-[13px]' : 'rounded-md px-3 py-2.5 text-[15px]',
100-
isSelected
101-
? 'bg-primary/10 text-primary'
102-
: 'text-foreground hover:bg-accent/70 hover:text-accent-foreground'
103-
)}
104-
onClick={() => {
105-
onValueChange(option.value)
106-
setOpen(false)
107-
}}
108-
>
109-
<span className="truncate">{option.label}</span>
110-
<Check className={cn('size-4 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')} />
111-
</button>
112-
)
113-
})}
114-
</div>
115-
</div>
116-
</div>
117-
) : null}
130+
{open && position
131+
? createPortal(
132+
<div
133+
ref={dropdownRef}
134+
style={{
135+
position: 'fixed',
136+
top: position.openUp ? undefined : position.top,
137+
bottom: position.openUp ? window.innerHeight - position.top : undefined,
138+
left: position.left,
139+
width: position.width,
140+
}}
141+
className={cn(
142+
'z-[1000] overflow-hidden border border-border bg-popover shadow-[0_18px_40px_hsl(222_30%_18%/0.12)] backdrop-blur-sm rounded-lg'
143+
)}
144+
>
145+
<div
146+
className={cn('overflow-auto', compact ? 'p-1' : 'p-2')}
147+
style={{ maxHeight: position.maxHeight }}
148+
>
149+
<div role="listbox" aria-activedescendant={value || undefined} className={compact ? 'space-y-0.5' : 'space-y-1'}>
150+
{options.map((option) => {
151+
const isSelected = option.value === value
152+
return (
153+
<button
154+
key={option.value}
155+
id={option.value}
156+
type="button"
157+
role="option"
158+
aria-selected={isSelected}
159+
className={cn(
160+
'flex w-full items-center justify-between gap-3 text-left transition-colors',
161+
compact ? 'rounded-md px-2 py-1.5 text-[13px]' : 'rounded-md px-3 py-2.5 text-[15px]',
162+
isSelected
163+
? 'bg-primary/10 text-primary'
164+
: 'text-foreground hover:bg-accent/70 hover:text-accent-foreground'
165+
)}
166+
onClick={() => {
167+
onValueChange(option.value)
168+
setOpen(false)
169+
}}
170+
>
171+
<span className="truncate">{option.label}</span>
172+
<Check className={cn('size-4 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')} />
173+
</button>
174+
)
175+
})}
176+
</div>
177+
</div>
178+
</div>,
179+
document.body
180+
)
181+
: null}
118182
</div>
119183
)
120184
}

0 commit comments

Comments
 (0)