diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx index 8295a8a..657a776 100644 --- a/components/InvoiceIntake.tsx +++ b/components/InvoiceIntake.tsx @@ -154,7 +154,7 @@ export function AddInvoiceButton() { )} {open && ( -
+
{/* Backdrop */}
(null); const inputRef = useRef(null); const listRef = useRef(null); + // The line table lives inside an overflow-x-auto scroll container, which CLIPS any + // absolutely-positioned child โ€” an in-place dropdown is cut off by the table edge and + // by the card. Rendering into with viewport coordinates is the only way out. + const [anchor, setAnchor] = useState<{ top: number; left: number; width: number } | null>(null); const selected = machines.find((m) => m.id === value) || null; + useLayoutEffect(() => { + if (!open || !inputRef.current) { + setAnchor(null); + return; + } + const measure = () => { + const r = inputRef.current?.getBoundingClientRect(); + if (r) setAnchor({ top: r.bottom, left: r.left, width: r.width }); + }; + measure(); + // Follow the page: the table scrolls, and so does the window. + window.addEventListener("scroll", measure, true); + window.addEventListener("resize", measure); + return () => { + window.removeEventListener("scroll", measure, true); + window.removeEventListener("resize", measure); + }; + }, [open]); + // Flattened, filtered list โ€” the flat order is what the keyboard walks. const groups = useMemo(() => { const q = query.trim().toLowerCase(); @@ -60,6 +84,19 @@ export function MachineCombobox({ }; }, [machines, query]); + const menuWidth = 300; + // ~16px count strip + a "Not booked" row (if any) + 12px per row, capped. + const menuHeight = useMemo(() => { + const rows = groups.flat.length; + return 44 + (value ? 44 : 0) + 24 + Math.min(rows, 8) * 44 + 8; + }, [groups.flat.length, value]); + + const flipUp = useMemo(() => { + if (typeof window === "undefined" || !anchor) return false; + return anchor.top + menuHeight + 8 > window.innerHeight && anchor.top - menuHeight - 8 > 8; + }, [anchor, menuHeight]); + + // Click away closes. useEffect(() => { if (!open) return; @@ -178,8 +215,17 @@ export function MachineCombobox({ )}
- {open && ( -
+ {open && anchor && createPortal( +
{groups.flat.length} of {machines.length} machines {!selected && " ยท type a code or name to narrow it"} @@ -242,7 +288,8 @@ export function MachineCombobox({ ))} )} -
+
, + document.body, )} {error && ( diff --git a/components/Toast.tsx b/components/Toast.tsx index 9829c86..f50c799 100644 --- a/components/Toast.tsx +++ b/components/Toast.tsx @@ -12,6 +12,10 @@ import { CheckCircle2, Info, X } from "lucide-react"; // // Announced politely rather than assertively โ€” a confirmation is not urgent, and it // should not interrupt whatever a screen reader is currently saying. +// +// z-[1300], not a low value: the sticky header is z-[1100] (it has to clear Leaflet's +// z-1000 control panes) and the mobile nav drawer is z-[1200]. Below those the toast +// rendered behind the nav bar. export function Toast({ message, detail, @@ -44,7 +48,7 @@ export function Toast({