fix(web): portal remaining overlay menus

This commit is contained in:
2026-03-30 14:20:05 +02:00
parent ea2efabd7f
commit 27b0e38b93
3 changed files with 111 additions and 71 deletions
@@ -1,5 +1,6 @@
"use client"; "use client";
import { createPortal } from "react-dom";
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -21,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -212,6 +214,17 @@ function TagAdder({
.filter((t) => t.toLowerCase().includes(lower) && !existingTags.includes(t)) .filter((t) => t.toLowerCase().includes(lower) && !existingTags.includes(t))
.slice(0, 8); .slice(0, 8);
}, [value, allKnownTags, existingTags]); }, [value, allKnownTags, existingTags]);
const { panelRef, position } = useAnchoredOverlay<HTMLInputElement>({
open: showSuggestions && suggestions.length > 0,
onClose: () => {
setShowSuggestions(false);
if (!value.trim()) {
onClose();
}
},
align: "start",
triggerRef: inputRef,
});
function submit(tag: string) { function submit(tag: string) {
const trimmed = tag.trim(); const trimmed = tag.trim();
@@ -250,24 +263,34 @@ function TagAdder({
placeholder="Tag..." placeholder="Tag..."
className="w-24 px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-400" className="w-24 px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-400"
/> />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && typeof document !== "undefined"
<div className="absolute z-20 top-full left-0 mt-1 w-40 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 max-h-32 overflow-y-auto"> ? createPortal(
{suggestions.map((s) => ( <div
<button ref={panelRef}
key={s} className="fixed z-[9998] max-h-32 w-40 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
type="button" style={{
onMouseDown={(e) => e.preventDefault()} top: position.top,
onClick={() => { left: position.left,
submit(s);
setShowSuggestions(false);
}} }}
className="block w-full text-left px-3 py-1 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<TagPill tag={s} /> {suggestions.map((s) => (
</button> <button
))} key={s}
</div> type="button"
)} onMouseDown={(e) => e.preventDefault()}
onClick={() => {
submit(s);
setShowSuggestions(false);
}}
className="block w-full text-left px-3 py-1 text-xs text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<TagPill tag={s} />
</button>
))}
</div>,
document.body,
)
: null}
</div> </div>
); );
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { createPortal } from "react-dom";
import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from "react"; import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
@@ -8,6 +9,7 @@ import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js"; import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js"; import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
import { useReferenceData } from "~/hooks/useReferenceData.js"; import { useReferenceData } from "~/hooks/useReferenceData.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
function UtilizationBar({ percent }: { percent: number }) { function UtilizationBar({ percent }: { percent: number }) {
const barColor = const barColor =
@@ -119,23 +121,18 @@ function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) {
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) { function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); const triggerRef = useRef<HTMLButtonElement | null>(null);
const { panelRef, position } = useAnchoredOverlay<HTMLButtonElement>({
useEffect(() => { open: isOpen,
function handlePointerDown(event: MouseEvent) { onClose: () => setIsOpen(false),
const target = event.target as Node; align: "end",
if (dropdownRef.current && !dropdownRef.current.contains(target)) { triggerRef,
setIsOpen(false); });
}
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
return ( return (
<div ref={dropdownRef} className="relative"> <div className="relative">
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setIsOpen((current) => !current)} onClick={() => setIsOpen((current) => !current)}
className="inline-flex min-w-44 items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-xs text-gray-700 shadow-sm transition hover:border-gray-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200" className="inline-flex min-w-44 items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-xs text-gray-700 shadow-sm transition hover:border-gray-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
@@ -143,11 +140,21 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
<span className="text-[10px] text-gray-400">{isOpen ? "▲" : "▼"}</span> <span className="text-[10px] text-gray-400">{isOpen ? "▲" : "▼"}</span>
</button> </button>
{isOpen ? ( {isOpen && typeof document !== "undefined"
<div className="absolute right-0 z-20 mt-2 w-72 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900"> ? createPortal(
{children} <div
</div> ref={panelRef}
) : null} className="fixed z-[9998] w-72 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900"
style={{
top: position.top,
left: position.left,
}}
>
{children}
</div>,
document.body,
)
: null}
</div> </div>
); );
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { createPortal } from "react-dom";
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import type { StaffingRequirement } from "@capakraken/shared"; import type { StaffingRequirement } from "@capakraken/shared";
@@ -13,6 +14,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js"; import { formatCents } from "~/lib/format.js";
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js"; import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -307,7 +309,7 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
const [query, setQuery] = useState(value); const [query, setQuery] = useState(value);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Debounce search query to avoid excessive API calls // Debounce search query to avoid excessive API calls
useEffect(() => { useEffect(() => {
@@ -331,21 +333,18 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
setQuery(value); setQuery(value);
}, [value]); }, [value]);
// Close on outside click const { panelRef, position } = useAnchoredOverlay<HTMLInputElement>({
useEffect(() => { open,
if (!open) return; onClose: () => setOpen(false),
function handleClick(e: MouseEvent) { align: "start",
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { matchTriggerWidth: true,
setOpen(false); triggerRef: inputRef,
} });
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return ( return (
<div ref={containerRef} className="relative"> <div className="relative">
<input <input
ref={inputRef}
type="text" type="text"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
@@ -357,29 +356,40 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v
placeholder="Search by name or EID…" placeholder="Search by name or EID…"
className={INPUT_CLS} className={INPUT_CLS}
/> />
{open && filtered.length > 0 && ( {open && filtered.length > 0 && typeof document !== "undefined"
<ul ? createPortal(
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto" <div
onMouseDown={(e) => e.preventDefault()} ref={panelRef}
> className="fixed z-[9998] max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg"
{filtered.map((r) => ( style={{
<li key={r.id}> top: position.top,
<button left: position.left,
type="button" width: position.minWidth,
onMouseDown={() => { }}
onChange(r.displayName); onMouseDown={(e) => e.preventDefault()}
setQuery(r.displayName); >
setOpen(false); <ul>
}} {filtered.map((r) => (
className="w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 hover:bg-gray-50 transition-colors" <li key={r.id}>
> <button
<span className="truncate">{r.displayName}</span> type="button"
<span className="text-xs text-gray-400 font-mono shrink-0">{r.eid}</span> onMouseDown={() => {
</button> onChange(r.displayName);
</li> setQuery(r.displayName);
))} setOpen(false);
</ul> }}
)} className="flex w-full items-baseline gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50"
>
<span className="truncate">{r.displayName}</span>
<span className="shrink-0 font-mono text-xs text-gray-400">{r.eid}</span>
</button>
</li>
))}
</ul>
</div>,
document.body,
)
: null}
</div> </div>
); );
} }