pelagia-portal/App/components/ui/searchable-select.tsx
Hardik ce24539640 fix(line-items): portal dropdown to escape overflow, hide scrollbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 02:23:39 +05:30

207 lines
7.6 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, useLayoutEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Search, X } from "lucide-react";
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
interface Props {
name: string;
value: string;
onChange: (value: string) => void;
groups: AccountGroup[];
placeholder?: string;
required?: boolean;
/** "default" for the main PO field, "compact" for the per-line-item table cell */
size?: "default" | "compact";
}
export function SearchableSelect({
name,
value,
onChange,
groups,
placeholder = "Select accounting code…",
required,
size = "default",
}: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [portalStyle, setPortalStyle] = useState<React.CSSProperties>({});
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// Recalculate portal position whenever the dropdown opens
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const PANEL_WIDTH = 420;
// Prefer right-aligning with the trigger; clamp so it doesn't go off-screen left
const right = window.innerWidth - rect.right;
const left = Math.max(8, rect.right - PANEL_WIDTH);
setPortalStyle({
position: "fixed",
top: rect.bottom + 4,
left,
width: PANEL_WIDTH,
zIndex: 9999,
});
}, [open]);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("keydown", handleKey);
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("keydown", handleKey);
document.removeEventListener("mousedown", handleClick);
};
}, [open]);
// Auto-focus search input when opened
useEffect(() => {
if (open) searchRef.current?.focus();
}, [open]);
const selectedItem = groups.flatMap((g) => g.items).find((i) => i.id === value);
const selectedLabel = selectedItem ? `${selectedItem.code}${selectedItem.name}` : "";
const q = query.trim().toLowerCase();
const filtered = q
? groups
.map((g) => ({
...g,
items: g.items.filter(
(i) => i.code.toLowerCase().includes(q) || i.name.toLowerCase().includes(q)
),
}))
.filter((g) => g.items.length > 0)
: groups;
const handleSelect = useCallback((id: string) => {
onChange(id);
setOpen(false);
setQuery("");
}, [onChange]);
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onChange("");
}, [onChange]);
const isCompact = size === "compact";
const dropdownPanel = (
<div
style={isCompact ? portalStyle : undefined}
className={`rounded-lg border border-neutral-200 bg-white shadow-xl
${isCompact ? "" : "absolute z-50 left-0 right-0 mt-1"}`}
>
{/* Search input */}
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type code or name…"
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
/>
{query && (
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Options list */}
<div className="max-h-72 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300">
{filtered.length === 0 ? (
<p className="px-3 py-5 text-sm text-center text-neutral-400">No codes match &ldquo;{query}&rdquo;</p>
) : (
filtered.map((group) => {
// Strip top-level breadcrumb — show only the sub-category part
const groupLabel = group.group.includes("")
? group.group.split("").pop()!.trim()
: group.group;
return (
<div key={group.group}>
<div className="sticky top-0 px-3 py-1 text-xs font-semibold text-neutral-500 bg-neutral-50 border-b border-neutral-100 truncate">
{groupLabel}
</div>
{group.items.map((item) => (
<button
key={item.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); handleSelect(item.id); }}
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
${value === item.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
>
<span className="font-mono text-xs text-neutral-400 shrink-0 w-14">{item.code}</span>
<span className="flex-1 leading-snug">{item.name}</span>
</button>
))}
</div>
);
})
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative w-full">
<input type="hidden" name={name} value={value} />
{required && !value && (
<input tabIndex={-1} required value={value} onChange={() => {}}
className="absolute opacity-0 w-0 h-0 pointer-events-none" aria-hidden />
)}
{/* Trigger button — same appearance for both sizes */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 rounded-lg border
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
bg-white text-left transition-colors
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20
${isCompact ? "px-2 py-1.5 text-xs" : "px-3 py-2.5 text-sm"}`}
>
<span className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}>
{selectedLabel || placeholder}
</span>
<span className="flex items-center gap-1 shrink-0">
{value && (
<span role="button" tabIndex={0} onClick={handleClear}
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
className="text-neutral-300 hover:text-neutral-500 transition-colors">
<X className={isCompact ? "h-3 w-3" : "h-4 w-4"} />
</span>
)}
<ChevronDown className={`text-neutral-400 transition-transform ${open ? "rotate-180" : ""} ${isCompact ? "h-3 w-3" : "h-4 w-4"}`} />
</span>
</button>
{/* Dropdown: compact uses a portal (fixed) to escape overflow-x-auto containers */}
{open && (
isCompact && mounted
? createPortal(dropdownPanel, document.body)
: dropdownPanel
)}
</div>
);
}