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:
parent
d471dc2d88
commit
5b9425873b
3 changed files with 57 additions and 6 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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"}`}
|
||||
|
|
|
|||
Loading…
Reference in a new issue