Files
CapaKraken/apps/web/src/components/ui/ResourceCombobox.tsx
T

141 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
interface ResourceComboboxProps {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
isActive?: boolean;
className?: string;
}
export function ResourceCombobox({
value,
onChange,
placeholder = "Search resource…",
disabled = false,
isActive = true,
className = "",
}: ResourceComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.resource.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, isActive },
{ enabled: open, staleTime: 30_000 },
);
const resources = data?.resources ?? [];
// Resolve display name for currently selected value
const { data: selectedData } = trpc.resource.list.useQuery(
{ search: undefined, limit: 500, isActive: undefined as unknown as boolean },
{ enabled: !!value && !open, staleTime: 60_000 },
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = resources.find((r) => r.id === value);
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
const fromSelected = selectedData?.resources.find((r) => r.id === value);
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
return value;
}, [value, resources, selectedData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleOpen() {
if (disabled) return;
setOpen(true);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
type="button"
onClick={handleOpen}
disabled={disabled}
className={`w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white disabled:opacity-50 disabled:cursor-not-allowed ${
open ? "border-brand-500 ring-2 ring-brand-500" : "hover:border-gray-400"
}`}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || placeholder}
</span>
{value && !disabled && (
<span
role="button"
tabIndex={0}
onMouseDown={(e) => { e.stopPropagation(); select(null); }}
onKeyDown={(e) => { if (e.key === "Enter") select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-lg leading-none"
aria-label="Clear"
>
×
</span>
)}
</button>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Type to search…"
className="w-full px-2 py-1 text-sm border-0 outline-none"
/>
</div>
<ul className="max-h-52 overflow-y-auto py-1">
{resources.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400">No results</li>
) : (
resources.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => select(r.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 ${
r.id === value ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-700"
}`}
>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400">{r.eid}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}