Dropdown menus paint under later glass panels - portal them out
The Signals > Setups filter dropdowns (Direction, Recommended Action) opened UNDER the panels below: glass containers use backdrop-filter, which creates a stacking context, so the menu's z-50 could never compete with later sibling panels regardless of value. The menu now portals to document.body with fixed positioning tracked against the trigger (re-measured on scroll/resize), and click-outside accounts for the portaled node. Applies to every Dropdown usage. Menu background also updated from the old green-tinted #151911 to the Horizon #14161f. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
export interface DropdownOption {
|
export interface DropdownOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -17,8 +18,12 @@ interface DropdownProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fully dark, custom dropdown — replaces native <select>, whose popup list
|
* Fully dark, custom dropdown — replaces native <select>, whose popup list
|
||||||
* can't be reliably styled (white background on Windows). Button + absolutely
|
* can't be reliably styled (white background on Windows).
|
||||||
* positioned menu, click-outside and Escape to close.
|
*
|
||||||
|
* The menu is PORTALED to document.body with fixed positioning: glass panels
|
||||||
|
* use backdrop-filter, which creates a stacking context, so an absolutely
|
||||||
|
* positioned menu inside one would paint UNDER any later sibling panel no
|
||||||
|
* matter its z-index.
|
||||||
*/
|
*/
|
||||||
export function Dropdown({
|
export function Dropdown({
|
||||||
value,
|
value,
|
||||||
@@ -30,14 +35,34 @@ export function Dropdown({
|
|||||||
placeholder = 'Select…',
|
placeholder = 'Select…',
|
||||||
}: DropdownProps) {
|
}: DropdownProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
const selected = options.find((o) => o.value === value);
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
// Track the button's viewport position while open (scroll/resize).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const measure = () => {
|
||||||
|
const r = rootRef.current?.getBoundingClientRect();
|
||||||
|
if (r) setRect({ top: r.bottom + 4, left: r.left, width: r.width });
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener('scroll', measure, true);
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', measure, true);
|
||||||
|
window.removeEventListener('resize', measure);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onDocClick = (e: MouseEvent) => {
|
const onDocClick = (e: MouseEvent) => {
|
||||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
const t = e.target as Node;
|
||||||
|
if (rootRef.current?.contains(t) || menuRef.current?.contains(t)) return;
|
||||||
|
setOpen(false);
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setOpen(false);
|
if (e.key === 'Escape') setOpen(false);
|
||||||
@@ -74,10 +99,12 @@ export function Dropdown({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && rect && createPortal(
|
||||||
<ul
|
<ul
|
||||||
|
ref={menuRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-white/[0.1] bg-[#151911] p-1 shadow-2xl shadow-black/50"
|
style={{ position: 'fixed', top: rect.top, left: rect.left, width: rect.width }}
|
||||||
|
className="z-50 max-h-64 overflow-auto rounded-lg border border-white/[0.1] bg-[#14161f] p-1 shadow-2xl shadow-black/50"
|
||||||
>
|
>
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const isSelected = opt.value === value;
|
const isSelected = opt.value === value;
|
||||||
@@ -109,7 +136,8 @@ export function Dropdown({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user