22
33/**
44 * Styled dropdown replacing the native <select>, whose option list renders unstyled (and often
5- * dark-on-dark / unreadable) across browsers. Same dark theme as the rest of the app; closes on
6- * outside-click or Escape. Drop-in: pass value/onChange/options.
5+ * dark-on-dark / unreadable) across browsers.
6+ *
7+ * The open list is rendered in a PORTAL at a fixed position anchored to the trigger. This is
8+ * deliberate: `.card` uses backdrop-blur, which creates a stacking context per card, so an
9+ * in-flow absolutely-positioned menu would be painted UNDER later cards. A portal escapes that.
10+ * Closes on outside-click, Escape, scroll or resize.
711 */
8- import { useEffect , useRef , useState } from 'react' ;
12+ import { useEffect , useLayoutEffect , useRef , useState } from 'react' ;
13+ import { createPortal } from 'react-dom' ;
914import { ChevronDown , Check } from 'lucide-react' ;
1015
1116export interface SelectOption {
@@ -27,53 +32,90 @@ export function Select({
2732 className ?: string ;
2833} ) {
2934 const [ open , setOpen ] = useState ( false ) ;
30- const ref = useRef < HTMLDivElement > ( null ) ;
35+ const btnRef = useRef < HTMLButtonElement > ( null ) ;
36+ const menuRef = useRef < HTMLDivElement > ( null ) ;
37+ const [ pos , setPos ] = useState ( { left : 0 , top : 0 , width : 0 } ) ;
3138 const current = options . find ( ( o ) => o . value === value ) ;
3239
40+ const place = ( ) => {
41+ const r = btnRef . current ?. getBoundingClientRect ( ) ;
42+ if ( r ) setPos ( { left : r . left , top : r . bottom + 4 , width : r . width } ) ;
43+ } ;
44+
45+ // After the menu mounts, flip it above the trigger if it would overflow the viewport bottom.
46+ useLayoutEffect ( ( ) => {
47+ if ( ! open ) return ;
48+ const r = btnRef . current ?. getBoundingClientRect ( ) ;
49+ const mh = menuRef . current ?. offsetHeight ?? 0 ;
50+ if ( ! r ) return ;
51+ let top = r . bottom + 4 ;
52+ if ( top + mh > window . innerHeight - 8 ) top = Math . max ( 8 , r . top - mh - 4 ) ;
53+ setPos ( { left : r . left , top, width : r . width } ) ;
54+ } , [ open ] ) ;
55+
3356 useEffect ( ( ) => {
3457 if ( ! open ) return ;
35- const onDoc = ( e : MouseEvent ) => {
36- if ( ref . current && ! ref . current . contains ( e . target as Node ) ) setOpen ( false ) ;
58+ const onDown = ( e : MouseEvent ) => {
59+ const target = e . target as Node ;
60+ if ( btnRef . current ?. contains ( target ) || menuRef . current ?. contains ( target ) ) return ;
61+ setOpen ( false ) ;
3762 } ;
3863 const onKey = ( e : KeyboardEvent ) => e . key === 'Escape' && setOpen ( false ) ;
39- document . addEventListener ( 'mousedown' , onDoc ) ;
64+ const close = ( ) => setOpen ( false ) ;
65+ document . addEventListener ( 'mousedown' , onDown ) ;
4066 document . addEventListener ( 'keydown' , onKey ) ;
67+ window . addEventListener ( 'scroll' , close , true ) ;
68+ window . addEventListener ( 'resize' , close ) ;
4169 return ( ) => {
42- document . removeEventListener ( 'mousedown' , onDoc ) ;
70+ document . removeEventListener ( 'mousedown' , onDown ) ;
4371 document . removeEventListener ( 'keydown' , onKey ) ;
72+ window . removeEventListener ( 'scroll' , close , true ) ;
73+ window . removeEventListener ( 'resize' , close ) ;
4474 } ;
4575 } , [ open ] ) ;
4676
4777 return (
48- < div ref = { ref } className = { `relative ${ className ?? '' } ` } >
78+ < div className = { className } >
4979 < button
80+ ref = { btnRef }
5081 type = "button"
51- onClick = { ( ) => setOpen ( ( o ) => ! o ) }
82+ onClick = { ( ) => {
83+ if ( ! open ) place ( ) ;
84+ setOpen ( ( o ) => ! o ) ;
85+ } }
5286 className = "flex w-full items-center justify-between gap-2 rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2.5 text-left text-sm text-zinc-100 transition hover:border-white/20 focus:border-accent/60 focus:outline-none focus:ring-2 focus:ring-accent/25"
5387 >
5488 < span className = { `truncate ${ current ? '' : 'text-zinc-500' } ` } > { current ?. label ?? placeholder ?? '' } </ span >
5589 < ChevronDown size = { 15 } className = { `shrink-0 text-zinc-400 transition ${ open ? 'rotate-180' : '' } ` } />
5690 </ button >
57- { open && (
58- < div className = "absolute left-0 right-0 z-40 mt-1 max-h-64 overflow-auto rounded-lg border border-white/10 bg-[#15151d] py-1 shadow-2xl ring-1 ring-black/40" >
59- { options . map ( ( o ) => (
60- < button
61- key = { o . value }
62- type = "button"
63- onClick = { ( ) => {
64- onChange ( o . value ) ;
65- setOpen ( false ) ;
66- } }
67- className = { `flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition hover:bg-white/[0.06] ${
68- o . value === value ? 'text-violet-200' : 'text-zinc-200'
69- } `}
70- >
71- < span className = "truncate" > { o . label } </ span >
72- { o . value === value && < Check size = { 14 } className = "shrink-0 text-violet-300" /> }
73- </ button >
74- ) ) }
75- </ div >
76- ) }
91+
92+ { open &&
93+ typeof document !== 'undefined' &&
94+ createPortal (
95+ < div
96+ ref = { menuRef }
97+ style = { { left : pos . left , top : pos . top , width : pos . width } }
98+ className = "fixed z-[95] max-h-64 overflow-auto rounded-lg border border-white/10 bg-[#15151d] py-1 shadow-2xl ring-1 ring-black/40"
99+ >
100+ { options . map ( ( o ) => (
101+ < button
102+ key = { o . value }
103+ type = "button"
104+ onClick = { ( ) => {
105+ onChange ( o . value ) ;
106+ setOpen ( false ) ;
107+ } }
108+ className = { `flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition hover:bg-white/[0.06] ${
109+ o . value === value ? 'text-violet-200' : 'text-zinc-200'
110+ } `}
111+ >
112+ < span className = "truncate" > { o . label } </ span >
113+ { o . value === value && < Check size = { 14 } className = "shrink-0 text-violet-300" /> }
114+ </ button >
115+ ) ) }
116+ </ div > ,
117+ document . body ,
118+ ) }
77119 </ div >
78120 ) ;
79121}
0 commit comments