diff --git a/apps/web/src/components/admin/ClientsAdminClient.tsx b/apps/web/src/components/admin/ClientsAdminClient.tsx index 043b6fe..f42aab0 100644 --- a/apps/web/src/components/admin/ClientsAdminClient.tsx +++ b/apps/web/src/components/admin/ClientsAdminClient.tsx @@ -1,5 +1,6 @@ "use client"; +import { createPortal } from "react-dom"; import { closestCenter, DndContext, @@ -21,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; +import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; // --------------------------------------------------------------------------- // Types @@ -212,6 +214,17 @@ function TagAdder({ .filter((t) => t.toLowerCase().includes(lower) && !existingTags.includes(t)) .slice(0, 8); }, [value, allKnownTags, existingTags]); + const { panelRef, position } = useAnchoredOverlay({ + open: showSuggestions && suggestions.length > 0, + onClose: () => { + setShowSuggestions(false); + if (!value.trim()) { + onClose(); + } + }, + align: "start", + triggerRef: inputRef, + }); function submit(tag: string) { const trimmed = tag.trim(); @@ -250,24 +263,34 @@ function TagAdder({ 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" /> - {showSuggestions && suggestions.length > 0 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} + {suggestions.map((s) => ( + + ))} + , + document.body, + ) + : null} ); } diff --git a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx index 36a64d9..7bc73ed 100644 --- a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx @@ -1,5 +1,6 @@ "use client"; +import { createPortal } from "react-dom"; import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from "react"; import { trpc } from "~/lib/trpc/client.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 { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js"; import { useReferenceData } from "~/hooks/useReferenceData.js"; +import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; function UtilizationBar({ percent }: { percent: number }) { const barColor = @@ -119,23 +121,18 @@ function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) { function FilterDropdown({ label, children }: { label: string; children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - useEffect(() => { - function handlePointerDown(event: MouseEvent) { - const target = event.target as Node; - if (dropdownRef.current && !dropdownRef.current.contains(target)) { - setIsOpen(false); - } - } - - document.addEventListener("mousedown", handlePointerDown); - return () => document.removeEventListener("mousedown", handlePointerDown); - }, []); + const triggerRef = useRef(null); + const { panelRef, position } = useAnchoredOverlay({ + open: isOpen, + onClose: () => setIsOpen(false), + align: "end", + triggerRef, + }); return ( -
+
- {isOpen ? ( -
- {children} -
- ) : null} + {isOpen && typeof document !== "undefined" + ? createPortal( +
+ {children} +
, + document.body, + ) + : null}
); } diff --git a/apps/web/src/components/projects/ProjectWizard.tsx b/apps/web/src/components/projects/ProjectWizard.tsx index 773497a..c2bbe1e 100644 --- a/apps/web/src/components/projects/ProjectWizard.tsx +++ b/apps/web/src/components/projects/ProjectWizard.tsx @@ -1,5 +1,6 @@ "use client"; +import { createPortal } from "react-dom"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { clsx } from "clsx"; import type { StaffingRequirement } from "@capakraken/shared"; @@ -13,6 +14,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { formatCents } from "~/lib/format.js"; import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; +import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -307,7 +309,7 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v const [query, setQuery] = useState(value); const [open, setOpen] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(""); - const containerRef = useRef(null); + const inputRef = useRef(null); // Debounce search query to avoid excessive API calls useEffect(() => { @@ -331,21 +333,18 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v setQuery(value); }, [value]); - // Close on outside click - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); + const { panelRef, position } = useAnchoredOverlay({ + open, + onClose: () => setOpen(false), + align: "start", + matchTriggerWidth: true, + triggerRef: inputRef, + }); return ( -
+
{ @@ -357,29 +356,40 @@ function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v placeholder="Search by name or EID…" className={INPUT_CLS} /> - {open && filtered.length > 0 && ( -
    e.preventDefault()} - > - {filtered.map((r) => ( -
  • - -
  • - ))} -
- )} + {open && filtered.length > 0 && typeof document !== "undefined" + ? createPortal( +
e.preventDefault()} + > +
    + {filtered.map((r) => ( +
  • + +
  • + ))} +
+
, + document.body, + ) + : null}
); }