Fix: toast and machine dropdown were rendering behind the page chrome

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 <body>, 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) <noreply@anthropic.com>
This commit is contained in:
David 2026-10-02 18:42:29 +02:00
parent d471dc2d88
commit 5b9425873b
3 changed files with 57 additions and 6 deletions

View file

@ -154,7 +154,7 @@ export function AddInvoiceButton() {
)}
{open && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="fixed inset-0 z-[1250] flex items-end sm:items-center justify-center p-0 sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useRef, useEffect, useMemo, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Loader2, AlertCircle, ChevronDown, Check, Search, X } from "lucide-react";
@ -33,9 +34,32 @@ export function MachineCombobox({
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(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 <body> 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({
)}
</div>
{open && (
<div className="absolute z-30 left-0 top-full mt-1 w-full min-w-[280px] max-w-[340px] rounded-xl bg-white dark:bg-[#1a222b] border border-gray-200 dark:border-white/10 shadow-xl overflow-hidden">
{open && anchor && createPortal(
<div
style={{
position: "fixed",
top: flipUp ? Math.max(8, anchor.top - menuHeight - 6) : anchor.top + 4,
left: Math.min(anchor.left, window.innerWidth - menuWidth - 8),
width: Math.max(anchor.width, menuWidth),
zIndex: 1300,
}}
className="rounded-xl bg-white dark:bg-[#1a222b] border border-gray-200 dark:border-white/10 shadow-2xl overflow-hidden"
>
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-white/5">
{groups.flat.length} of {machines.length} machines
{!selected && " · type a code or name to narrow it"}
@ -242,7 +288,8 @@ export function MachineCombobox({
))}
</ul>
)}
</div>
</div>,
document.body,
)}
{error && (

View file

@ -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({
<div
role="status"
aria-live="polite"
className={`pointer-events-none fixed z-[60] inset-x-3 sm:inset-x-auto sm:right-6 sm:w-auto sm:max-w-sm
className={`pointer-events-none fixed z-[1300] inset-x-3 sm:inset-x-auto sm:right-6 sm:w-auto sm:max-w-sm
pt-[max(0.75rem,env(safe-area-inset-top))] sm:pt-0 sm:top-6
transition-all duration-200 ease-out
${leaving ? "opacity-0 -translate-y-2" : "opacity-100 translate-y-0"}`}