11import { 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'
34import { cn } from '@/lib/utils'
45
56export 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+
2033export 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