From 5b9425873bc99ce2a30fb525255fe85c890769a1 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 2 Oct 2026 18:42:29 +0200 Subject: [PATCH] Fix: toast and machine dropdown were rendering behind the page chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two different causes, both from the same root — the header was raised to z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over it, which quietly pushed my overlays underneath it. The toast sat at z-60 and the Add Invoice dialog at z-50, both below the header, so a confirmation appeared to vanish behind the nav bar. Rung out against the existing ladder instead of picking a new number: header 1100, nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit at the same layer deliberately — they never open at the same time. The machine dropdown was a separate and worse bug: it was not merely behind anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto container, so the list was cut off by the table edge and the card around it — it could never escape, which is why the search appeared to do nothing. It now renders through a portal into , positioned from the input's measured viewport rect, so no ancestor can clip it. The rect is measured before paint so it never flashes in the wrong place, kept in sync with scroll and resize, and the list flips upward when there is not enough room below — which is the common case on a phone with the keyboard open. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/InvoiceIntake.tsx | 2 +- components/MachineCombobox.tsx | 55 +++++++++++++++++++++++++++++++--- components/Toast.tsx | 6 +++- 3 files changed, 57 insertions(+), 6 deletions(-) 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({