pelagia-portal/App/components/ui/searchable-select.tsx
Hardik a9c125c21c fix(searchable-select): compact mode dropdown positioning and group labels
- Compact dropdown is now right-anchored at fixed 380px width so it
  extends leftward from the trigger and doesn't overflow the table edge
- Group headers in compact mode show only the sub-category (strip the
  top-level breadcrumb before the arrow) and are single-line truncated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 02:19:32 +05:30

211 lines
7.2 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, useCallback } from "react";
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);
// 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]);
// Find the display label for the current value
const selectedItem = groups.flatMap((g) => g.items).find((i) => i.id === value);
const selectedLabel = selectedItem ? `${selectedItem.code}${selectedItem.name}` : "";
// Filter by query (code or name, case-insensitive)
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";
return (
<div ref={containerRef} className="relative w-full">
{/* Hidden input for form submission */}
<input type="hidden" name={name} value={value} />
{required && !value && (
/* Invisible trick to trigger native "required" validation on submit */
<input
tabIndex={-1}
required
value={value}
onChange={() => {}}
className="absolute opacity-0 w-0 h-0 pointer-events-none"
aria-hidden
/>
)}
{/* Trigger */}
<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 panel
default: full-width anchored left
compact: fixed 380px anchored to right edge of trigger (won't overflow table) */}
{open && (
<div
className={`absolute z-50 mt-1 rounded-lg border border-neutral-200 bg-white shadow-xl
${isCompact
? "right-0 w-[380px]"
: "left-0 right-0"
}`}
>
{/* 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 */}
<div className="max-h-64 overflow-y-auto overscroll-contain">
{filtered.length === 0 ? (
<p className="px-3 py-5 text-sm text-center text-neutral-400">No codes match "{query}"</p>
) : (
filtered.map((group) => {
// In compact mode show only the sub-category (after ), not the full breadcrumb
const groupLabel = isCompact && group.group.includes("")
? group.group.split("").pop()!.trim()
: group.group;
return (
<div key={group.group}>
{/* Group header */}
<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>
{/* Items */}
{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>
)}
</div>
);
}