diff --git a/App/components/po/po-line-items-editor.tsx b/App/components/po/po-line-items-editor.tsx index a2b975d..65f3c93 100644 --- a/App/components/po/po-line-items-editor.tsx +++ b/App/components/po/po-line-items-editor.tsx @@ -350,7 +350,7 @@ export function LineItemsEditor({ return (
-
+
diff --git a/App/components/ui/searchable-select.tsx b/App/components/ui/searchable-select.tsx index d36e597..4e2dc1b 100644 --- a/App/components/ui/searchable-select.tsx +++ b/App/components/ui/searchable-select.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; import { ChevronDown, Search, X } from "lucide-react"; import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form"; @@ -28,6 +29,27 @@ export function SearchableSelect({ const [query, setQuery] = useState(""); const containerRef = useRef(null); const searchRef = useRef(null); + const [portalStyle, setPortalStyle] = useState({}); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + // Recalculate portal position whenever the dropdown opens + useLayoutEffect(() => { + if (!open || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const PANEL_WIDTH = 420; + // Prefer right-aligning with the trigger; clamp so it doesn't go off-screen left + const right = window.innerWidth - rect.right; + const left = Math.max(8, rect.right - PANEL_WIDTH); + setPortalStyle({ + position: "fixed", + top: rect.bottom + 4, + left, + width: PANEL_WIDTH, + zIndex: 9999, + }); + }, [open]); // Close on outside click / Escape useEffect(() => { @@ -54,61 +76,102 @@ export function SearchableSelect({ if (open) searchRef.current?.focus(); }, [open]); - // Find the display label for the current value const selectedItem = groups.flatMap((g) => g.items).find((i) => i.id === value); const selectedLabel = selectedItem ? `${selectedItem.code} — ${selectedItem.name}` : ""; - // Filter by query (code or name, case-insensitive) const q = query.trim().toLowerCase(); const filtered = q ? groups .map((g) => ({ ...g, items: g.items.filter( - (i) => - i.code.toLowerCase().includes(q) || - i.name.toLowerCase().includes(q) + (i) => i.code.toLowerCase().includes(q) || i.name.toLowerCase().includes(q) ), })) .filter((g) => g.items.length > 0) : groups; - const handleSelect = useCallback( - (id: string) => { - onChange(id); - setOpen(false); - setQuery(""); - }, - [onChange] - ); + const handleSelect = useCallback((id: string) => { + onChange(id); + setOpen(false); + setQuery(""); + }, [onChange]); - const handleClear = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onChange(""); - }, - [onChange] - ); + const handleClear = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onChange(""); + }, [onChange]); const isCompact = size === "compact"; + const dropdownPanel = ( +
+ {/* Search input */} +
+ + setQuery(e.target.value)} + placeholder="Type code or name…" + className="flex-1 text-sm outline-none placeholder:text-neutral-400" + /> + {query && ( + + )} +
+ + {/* Options list */} +
+ {filtered.length === 0 ? ( +

No codes match “{query}”

+ ) : ( + filtered.map((group) => { + // Strip top-level breadcrumb — show only the sub-category part + const groupLabel = group.group.includes("›") + ? group.group.split("›").pop()!.trim() + : group.group; + return ( +
+
+ {groupLabel} +
+ {group.items.map((item) => ( + + ))} +
+ ); + }) + )} +
+
+ ); + return (
- {/* Hidden input for form submission */} {required && !value && ( - /* Invisible trick to trigger native "required" validation on submit */ - {}} - className="absolute opacity-0 w-0 h-0 pointer-events-none" - aria-hidden - /> + {}} + className="absolute opacity-0 w-0 h-0 pointer-events-none" aria-hidden /> )} - {/* Trigger */} + {/* Trigger button — same appearance for both sizes */} - {/* Dropdown panel - default: full-width anchored left - compact: fixed 380px anchored to right edge of trigger (won't overflow table) */} + {/* Dropdown: compact uses a portal (fixed) to escape overflow-x-auto containers */} {open && ( -
- {/* Search input */} -
- - setQuery(e.target.value)} - placeholder="Type code or name…" - className="flex-1 text-sm outline-none placeholder:text-neutral-400" - /> - {query && ( - - )} -
- - {/* Options */} -
- {filtered.length === 0 ? ( -

No codes match "{query}"

- ) : ( - filtered.map((group) => { - // In compact mode show only the sub-category (after ›), not the full breadcrumb - const groupLabel = isCompact && group.group.includes("›") - ? group.group.split("›").pop()!.trim() - : group.group; - return ( -
- {/* Group header */} -
- {groupLabel} -
- {/* Items */} - {group.items.map((item) => ( - - ))} -
- ); - }) - )} -
-
+ isCompact && mounted + ? createPortal(dropdownPanel, document.body) + : dropdownPanel )}
);