search option added on select and workflow lookups

This commit is contained in:
suryacp23 2026-07-29 10:38:55 +05:30
parent f8abf9b553
commit 7183e27567

View File

@ -1,5 +1,6 @@
import type { SelectHTMLAttributes } from 'react'; import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react';
import { ChevronDown } from 'lucide-react'; import { createPortal } from 'react-dom';
import { ChevronDown, Search, X, Check } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
export interface SelectOption { export interface SelectOption {
@ -11,43 +12,208 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string; label?: string;
hint?: string; hint?: string;
error?: string; error?: string;
placeholder?: string;
/** Either strings or {value,label} objects. */ /** Either strings or {value,label} objects. */
options?: Array<string | SelectOption>; options?: Array<string | SelectOption>;
/** Class for the outer label wrapper. */ /** Class for the outer label wrapper. */
className?: string; className?: string;
/** Disable search header filter if set to false */
searchable?: boolean;
} }
/** Labeled native select styled to match Input. */ /** Custom searchable select component using React Portal to prevent container clipping. */
export function Select({ label, hint, error, options = [], required, className, ...rest }: SelectProps) { export function Select({
label,
hint,
error,
options = [],
required,
className,
disabled,
placeholder,
searchable = true,
...rest
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const normalizedOptions: SelectOption[] = options.map((o) =>
typeof o === 'string' ? { value: o, label: o } : { value: String(o.value ?? ''), label: o.label }
);
const currentValue = String(rest.value ?? '');
const selectedOption = normalizedOptions.find((o) => o.value === currentValue);
const selectedOptionLabel = selectedOption ? selectedOption.label : '';
const filteredOptions = normalizedOptions.filter((o) =>
o.label.toLowerCase().includes(searchQuery.toLowerCase())
);
const updatePosition = useCallback(() => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const dropdownHeight = 260; // Max height approximation
const spaceBelow = window.innerHeight - rect.bottom;
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
setDropdownStyle({
position: 'fixed',
left: `${rect.left}px`,
width: `${rect.width}px`,
zIndex: 999999,
...(openUpwards
? { bottom: `${window.innerHeight - rect.top + 4}px` }
: { top: `${rect.bottom + 4}px` }),
});
}
}, []);
useEffect(() => {
if (isOpen) {
updatePosition();
const handleScrollOrResize = () => updatePosition();
window.addEventListener('resize', handleScrollOrResize);
window.addEventListener('scroll', handleScrollOrResize, true);
return () => {
window.removeEventListener('resize', handleScrollOrResize);
window.removeEventListener('scroll', handleScrollOrResize, true);
};
}
}, [isOpen, updatePosition]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
const isOutsideContainer = containerRef.current && !containerRef.current.contains(target);
const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target);
if (isOutsideContainer && isOutsideDropdown) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (val: string) => {
if (rest.onChange) {
const syntheticEvent = {
target: { value: val, name: rest.name, id: rest.id },
currentTarget: { value: val, name: rest.name, id: rest.id },
} as unknown as React.ChangeEvent<HTMLSelectElement>;
rest.onChange(syntheticEvent);
}
setIsOpen(false);
setSearchQuery('');
};
return ( return (
<label className={cn('flex flex-col gap-1.5 font-sans', className)}> <div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}>
{label && ( {label && (
<span className="text-sm font-semibold text-slate-700"> <label className="text-sm font-semibold text-slate-700">
{label} {label}
{required && <span className="text-ruby-600"> *</span>} {required && <span className="text-ruby-600"> *</span>}
</span> </label>
)} )}
<div className={cn("relative bg-card rounded-md h-[42px] border transition-[border-color,box-shadow] duration-150 focus-ring", error ? "border-ruby-600" : "border-border-default")}>
<select {/* Trigger Box */}
required={required} <div
className="w-full h-full border-none outline-none bg-transparent appearance-none pl-3 pr-9 font-sans text-base text-strong cursor-pointer" onClick={() => !disabled && setIsOpen(!isOpen)}
{...rest} className={cn(
> "relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
{options.map((o) => { disabled && "opacity-60 cursor-not-allowed bg-slate-50",
const val = typeof o === 'string' ? o : o.value; isOpen ? "border-navy-600 ring-2 ring-navy-600/20" : error ? "border-ruby-600" : "border-border-default"
const lab = typeof o === 'string' ? o : o.label; )}
return ( >
<option key={val} value={val}> <span className={cn("text-base truncate pr-2", selectedOptionLabel ? "text-strong" : "text-faint")}>
{lab} {selectedOptionLabel || placeholder || "Select..."}
</option> </span>
); <ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
})}
</select>
<ChevronDown size={15} className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-faint" />
</div> </div>
{/* Portaled Dropdown Menu Overlay (bypasses parent overflow:hidden clipping) */}
{isOpen && !disabled && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-64 animate-in fade-in-50 duration-100"
onClick={(e) => e.stopPropagation()}
>
{searchable && (
<div className="p-2 border-b border-border-subtle bg-slate-50 flex items-center gap-2 shrink-0">
<Search size={14} className="text-muted shrink-0 ml-1" />
<input
type="text"
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search options..."
className="w-full text-xs bg-transparent border-none outline-none text-foreground placeholder:text-muted"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="text-muted hover:text-foreground p-0.5 rounded cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
)}
<div className="overflow-y-auto flex-1 py-1">
{filteredOptions.length > 0 ? (
filteredOptions.map((opt) => {
const isSelected = opt.value === currentValue;
return (
<div
key={opt.value}
onClick={() => handleSelect(opt.value)}
className={cn(
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors",
isSelected ? "bg-navy-50/20 text-navy-700 font-semibold" : "hover:bg-black/5 text-foreground"
)}
>
<span className="truncate">{opt.label}</span>
{isSelected && <Check size={14} className="text-navy-600 shrink-0 ml-2" />}
</div>
);
})
) : (
<div className="px-3 py-3 text-xs text-muted text-center italic">
No matching options
</div>
)}
</div>
</div>,
document.body
)}
{/* Hidden Native Select for Required/Form Validation */}
<select
required={required}
value={currentValue}
disabled={disabled}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
onChange={() => {}}
>
{normalizedOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{(hint || error) && ( {(hint || error) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span> <span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
)} )}
</label> </div>
); );
} }