Skip to content

Commit 5a63e2d

Browse files
Fix custom Select dropdown painting under later cards
.card uses backdrop-blur, which creates a stacking context per card, so the dropdown's absolute z-40 list was trapped in its card and overlapped by subsequent cards. Render the open list in a portal at a fixed, trigger-anchored position (flips above when it would overflow the viewport); closes on outside-click, Escape, scroll or resize.
1 parent b978308 commit 5a63e2d

1 file changed

Lines changed: 72 additions & 30 deletions

File tree

apps/web/src/components/ui/Select.tsx

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
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';
914
import { ChevronDown, Check } from 'lucide-react';
1015

1116
export 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

Comments
 (0)