refactor(select): enhance dropdown positioning and implement portal for improved rendering

This commit is contained in:
asepharyana
2026-08-26 17:37:20 +07:00
parent 54e7220d06
commit 3b8fe1b1c3
@@ -1,7 +1,14 @@
"use client"; "use client";
import { Check, ChevronDown } from "lucide-react"; import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface SelectOption { export interface SelectOption {
@@ -10,6 +17,11 @@ export interface SelectOption {
hint?: string; hint?: string;
} }
/**
* Custom select whose dropdown is portalled to `document.body` so it
* never gets clipped by a parent `overflow-y-auto` or `overflow-hidden`.
* Position is computed via `getBoundingClientRect()` + fixed positioning.
*/
export function Select({ export function Select({
value, value,
onChange, onChange,
@@ -26,76 +38,129 @@ export function Select({
size?: "sm" | "md"; size?: "sm" | "md";
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number; width: number }>({
top: 0,
left: 0,
width: 0,
});
// Measure trigger to position the portalled dropdown
const measure = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 6, left: rect.left, width: rect.width });
}, []);
useLayoutEffect(() => {
if (open) measure();
}, [open, measure]);
// Reposition on scroll / resize while open
useEffect(() => {
if (!open) return;
const onReposition = () => measure();
window.addEventListener("scroll", onReposition, true);
window.addEventListener("resize", onReposition);
return () => {
window.removeEventListener("scroll", onReposition, true);
window.removeEventListener("resize", onReposition);
};
}, [open, measure]);
// Close on outside click (covers both trigger and dropdown)
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDoc = (e: MouseEvent) => { const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) const t = e.target as Node;
setOpen(false); if (triggerRef.current?.contains(t) || dropdownRef.current?.contains(t))
return;
setOpen(false);
}; };
document.addEventListener("mousedown", onDoc); document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc);
}, [open]); }, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const selected = options.find((o) => o.value === value); const selected = options.find((o) => o.value === value);
return ( return (
<div ref={ref} className={cn("relative", className)}> <>
<button <div className={cn("relative", className)}>
type="button" <button
onClick={() => setOpen((o) => !o)} ref={triggerRef}
className={cn( type="button"
"flex w-full items-center justify-between gap-2 rounded-[8px] border border-hairline bg-surface-2 text-left text-ink transition-colors hover:border-signal/40", onClick={() => setOpen((o) => !o)}
"focus:outline-none focus:border-signal/60",
size === "sm" ? "h-9 px-3 text-xs" : "h-10 px-3.5 text-sm",
)}
>
<span className={cn("truncate", !selected && "text-ink-faint")}>
{selected?.label ?? placeholder}
</span>
<ChevronDown
className={cn( className={cn(
"size-4 shrink-0 text-ink-faint transition-transform", "flex w-full items-center justify-between gap-2 rounded-[8px] border border-hairline bg-surface-2 text-left text-ink transition-colors hover:border-signal/40",
open && "rotate-180", "focus:outline-none focus:border-signal/60",
size === "sm" ? "h-9 px-3 text-xs" : "h-10 px-3.5 text-sm",
)} )}
/>
</button>
{open && (
<div
className="glass absolute z-50 mt-1.5 max-h-72 w-full overflow-auto p-1.5"
style={{ animation: "fade-up 0.14s ease" }}
> >
{options.length === 0 && ( <span className={cn("truncate", !selected && "text-ink-faint")}>
<div className="px-3 py-2 text-xs text-ink-faint">No options</div> {selected?.label ?? placeholder}
)} </span>
{options.map((o) => ( <ChevronDown
<button className={cn(
key={o.value} "size-4 shrink-0 text-ink-faint transition-transform",
type="button" open && "rotate-180",
onClick={() => { )}
onChange(o.value); />
setOpen(false); </button>
}} </div>
className={cn(
"flex w-full items-center justify-between gap-2 rounded-[8px] px-3 py-2 text-left text-sm transition-colors", {open &&
o.value === value createPortal(
? "bg-signal/15 text-signal" <div
: "text-ink hover:bg-surface", ref={dropdownRef}
)} className="glass fixed z-[9999] max-h-72 overflow-auto p-1.5"
> style={{
<span className="truncate">{o.label}</span> top: pos.top,
{o.hint && ( left: pos.left,
<span className="mono text-[0.65rem] text-ink-faint"> width: pos.width,
{o.hint} animation: "fade-up 0.14s ease",
</span> }}
)} >
{o.value === value && <Check className="size-3.5 shrink-0" />} {options.length === 0 && (
</button> <div className="px-3 py-2 text-xs text-ink-faint">No options</div>
))} )}
</div> {options.map((o) => (
)} <button
</div> key={o.value}
type="button"
onClick={() => {
onChange(o.value);
setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-[8px] px-3 py-2 text-left text-sm transition-colors",
o.value === value
? "bg-signal/15 text-signal"
: "text-ink hover:bg-surface",
)}
>
<span className="truncate">{o.label}</span>
{o.hint && (
<span className="mono text-[0.65rem] text-ink-faint">
{o.hint}
</span>
)}
{o.value === value && <Check className="size-3.5 shrink-0" />}
</button>
))}
</div>,
document.body,
)}
</>
); );
} }