(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({