fix(web): portal remaining overlay menus
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user