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>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
"use client";
|
|
|
|
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";
|
|
|
|
type Machine = { id: string; code: string; name: string; category: string };
|
|
|
|
// A searchable replacement for the native <select> of 44 machines.
|
|
//
|
|
// A native select cannot be filtered, so finding "TLB07" meant scrolling a list that
|
|
// repeats two dozen near-identical "CASE TLB" entries. This filters as you type, on
|
|
// either the code or the name, and still behaves like a select: opening it, arrowing,
|
|
// Enter to choose, Escape to cancel, and a clear option.
|
|
//
|
|
// Keyboard and touch are both first-class — arrows move the highlighted row, the list
|
|
// scrolls, and every row is a 44px target so it is usable on a phone.
|
|
export function MachineCombobox({
|
|
lineId,
|
|
value,
|
|
machines,
|
|
}: {
|
|
lineId: string;
|
|
value: string;
|
|
machines: Machine[];
|
|
}) {
|
|
const router = useRouter();
|
|
const [query, setQuery] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [highlight, setHighlight] = useState(0);
|
|
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();
|
|
const hit = (m: Machine) =>
|
|
!q || m.code.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.category.toLowerCase().includes(q);
|
|
const matched = machines.filter(hit);
|
|
const byCategory = new Map<string, Machine[]>();
|
|
for (const m of matched) {
|
|
if (!byCategory.has(m.category)) byCategory.set(m.category, []);
|
|
byCategory.get(m.category)!.push(m);
|
|
}
|
|
return {
|
|
list: Array.from(byCategory.entries())
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([category, items]) => ({
|
|
category,
|
|
items: items.sort((a, b) => a.code.localeCompare(b.code)),
|
|
})),
|
|
flat: Array.from(byCategory.values())
|
|
.flat()
|
|
.sort((a, b) => a.code.localeCompare(b.code)),
|
|
};
|
|
}, [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;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
return () => document.removeEventListener("mousedown", onDown);
|
|
}, [open]);
|
|
|
|
// Keep the highlighted row inside the results after each keystroke.
|
|
useEffect(() => {
|
|
setHighlight((h) => Math.min(h, Math.max(0, groups.flat.length - 1)));
|
|
}, [groups.flat.length]);
|
|
|
|
// Scroll the highlighted row into view.
|
|
useEffect(() => {
|
|
if (!open || !listRef.current) return;
|
|
const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${highlight}"]`);
|
|
el?.scrollIntoView({ block: "nearest" });
|
|
}, [highlight, open]);
|
|
|
|
async function book(id: string) {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`/api/invoice-lines/${lineId}/book`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ assetId: id }),
|
|
});
|
|
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
|
if (!data.ok) setError(data.error || "Could not book it");
|
|
else {
|
|
setOpen(false);
|
|
setQuery("");
|
|
router.refresh();
|
|
}
|
|
} catch {
|
|
setError("Could not reach the server");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function onKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Escape") {
|
|
setOpen(false);
|
|
setQuery("");
|
|
return;
|
|
}
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
const pick = groups.flat[highlight];
|
|
if (pick) book(pick.id);
|
|
return;
|
|
}
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
if (!open) {
|
|
setOpen(true);
|
|
return;
|
|
}
|
|
const n = groups.flat.length;
|
|
if (!n) return;
|
|
setHighlight((h) => (e.key === "ArrowDown" ? (h + 1) % n : (h - 1 + n) % n));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div ref={boxRef} className="relative min-w-[200px]">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
aria-controls={`machine-list-${lineId}`}
|
|
value={open ? query : selected ? `${selected.code} · ${selected.name}` : ""}
|
|
placeholder="Search machines…"
|
|
onFocus={() => setOpen(true)}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setOpen(true);
|
|
setHighlight(0);
|
|
}}
|
|
onKeyDown={onKeyDown}
|
|
className={`min-h-[44px] w-full pl-8 pr-8 rounded-lg border text-sm transition focus:outline-none focus:border-[#30638E] ${
|
|
selected
|
|
? "border-[#00798C]/30 bg-[#00798C]/5 text-[#003D5B] dark:text-white font-medium"
|
|
: "border-[#D1495B]/40 bg-[#D1495B]/5 text-[#D1495B]"
|
|
}`}
|
|
/>
|
|
{busy ? (
|
|
<Loader2 className="w-4 h-4 animate-spin text-[#00798C] absolute right-2.5 top-1/2 -translate-y-1/2" />
|
|
) : (
|
|
<ChevronDown
|
|
className={`w-4 h-4 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 transition-transform pointer-events-none ${
|
|
open ? "rotate-180" : ""
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
{selected && !busy && (
|
|
<button
|
|
type="button"
|
|
onClick={() => book("")}
|
|
aria-label="Clear the machine booking"
|
|
className="grid place-items-center w-11 h-11 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition shrink-0"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{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"}
|
|
</div>
|
|
|
|
{value && (
|
|
<ul>
|
|
<li>
|
|
<button
|
|
type="button"
|
|
onClick={() => book("")}
|
|
className="w-full min-h-[44px] px-3 py-2 text-left text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
Not booked
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
)}
|
|
|
|
{groups.flat.length === 0 ? (
|
|
<p className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
No machine matches “{query}”.
|
|
</p>
|
|
) : (
|
|
<ul ref={listRef} id={`machine-list-${lineId}`} className="max-h-64 overflow-y-auto py-1">
|
|
{groups.list.map((g) => (
|
|
<li key={g.category}>
|
|
<div className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
{g.category}
|
|
</div>
|
|
<ul>
|
|
{g.items.map((m) => {
|
|
const idx = groups.flat.indexOf(m);
|
|
const isSel = m.id === value;
|
|
return (
|
|
<li key={m.id}>
|
|
<button
|
|
type="button"
|
|
data-idx={idx}
|
|
onMouseEnter={() => setHighlight(idx)}
|
|
onClick={() => book(m.id)}
|
|
className={`w-full min-h-[44px] px-3 py-2 text-left text-sm flex items-center gap-2 transition ${
|
|
idx === highlight
|
|
? "bg-[#00798C]/10 text-[#003D5B] dark:text-white"
|
|
: "text-gray-600 dark:text-gray-300"
|
|
}`}
|
|
>
|
|
<span className="font-mono text-xs font-semibold text-[#00798C] w-16 shrink-0">
|
|
{m.code || "—"}
|
|
</span>
|
|
<span className="truncate">{m.name}</span>
|
|
{isSel && <Check className="w-4 h-4 text-[#00798C] ml-auto shrink-0" />}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mt-1 flex items-start gap-1 text-sm text-[#D1495B]">
|
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|