From 3b8fe1b1c35d781b70bdd3ba08547feddfbedcd0 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 26 Aug 2026 17:37:20 +0700 Subject: [PATCH] refactor(select): enhance dropdown positioning and implement portal for improved rendering --- .../src/components/primitives/select.tsx | 179 ++++++++++++------ 1 file changed, 122 insertions(+), 57 deletions(-) diff --git a/services/frontend/src/components/primitives/select.tsx b/services/frontend/src/components/primitives/select.tsx index 0e0e9d1e..9cfe2313 100644 --- a/services/frontend/src/components/primitives/select.tsx +++ b/services/frontend/src/components/primitives/select.tsx @@ -1,7 +1,14 @@ "use client"; 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"; export interface SelectOption { @@ -10,6 +17,11 @@ export interface SelectOption { 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({ value, onChange, @@ -26,76 +38,129 @@ export function Select({ size?: "sm" | "md"; }) { const [open, setOpen] = useState(false); - const ref = useRef(null); + const triggerRef = useRef(null); + const dropdownRef = useRef(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(() => { if (!open) return; const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); + const t = e.target as Node; + if (triggerRef.current?.contains(t) || dropdownRef.current?.contains(t)) + return; + setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [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); return ( -
- - - {open && ( -
- {options.length === 0 && ( -
No options
- )} - {options.map((o) => ( - - ))} -
- )} -
+ + {selected?.label ?? placeholder} + + + + + + {open && + createPortal( +
+ {options.length === 0 && ( +
No options
+ )} + {options.map((o) => ( + + ))} +
, + document.body, + )} + ); }