feat(ui): add portaled Select component used by lead verification modals

This commit is contained in:
Satyam Rastogi
2026-05-30 04:22:33 +05:30
parent 41d90010b9
commit 6cbb1d729c
+179
View File
@@ -0,0 +1,179 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, Check } from 'lucide-react';
/**
* Reusable Select — a portaled custom dropdown that renders correctly in both
* light and dark mode (native <select> option lists fall back to the OS light
* theme, which is the bug this replaces). Styling matches the CRM's existing
* filter controls (rounded-xl, zinc palette, framer-motion chevron).
*
* Props:
* value — currently selected value
* onChange — (value) => void
* options — Array<{ value, label }> | Array<string>
* placeholder— shown when no value matches
* id — forwarded to the trigger (pairs with <label htmlFor>)
* ariaLabel — accessible label when there's no visible <label>
* className — extra classes for the trigger button
*/
export default function Select({
value,
onChange,
options = [],
placeholder = 'Select…',
id,
ariaLabel,
className = '',
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// Normalize string options to { value, label }
const items = useMemo(
() => options.map(o => (typeof o === 'string' ? { value: o, label: o } : o)),
[options],
);
const selected = items.find(o => o.value === value);
const computePos = () => {
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect();
setPos({
top: r.bottom + window.scrollY + 4,
left: r.left + window.scrollX,
width: r.width,
});
}
};
const handleToggle = () => {
if (!open) computePos();
setOpen(o => !o);
};
// Close on outside click — check both trigger and portaled dropdown
useEffect(() => {
if (!open) return;
const handler = (e) => {
const inTrigger = triggerRef.current?.contains(e.target);
const inDropdown = dropdownRef.current?.contains(e.target);
if (!inTrigger && !inDropdown) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Close on page scroll / resize — but not when scrolling inside the dropdown
useEffect(() => {
if (!open) return;
const close = (e) => {
const path = e.composedPath?.() ?? [];
if (dropdownRef.current && path.includes(dropdownRef.current)) return;
setOpen(false);
};
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
return (
<>
{/* Trigger */}
<button
ref={triggerRef}
id={id}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={handleToggle}
className={`w-full flex items-center justify-between pl-3 pr-2 py-2 text-sm rounded-xl
bg-zinc-100 dark:bg-white/5 border outline-none transition-all duration-150
text-zinc-900 dark:text-white
${open
? 'border-blue-400 dark:border-blue-500 ring-2 ring-blue-500/20'
: 'border-zinc-200 dark:border-white/10'}
${className}`}
>
<span className="truncate text-left">
{selected ? selected.label : placeholder}
</span>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
className="ml-1.5 shrink-0"
>
<ChevronDown size={14} className="text-zinc-400" />
</motion.span>
</button>
{/* Portaled dropdown — escapes any overflow:hidden ancestor */}
{createPortal(
<AnimatePresence>
{open && pos && (
<motion.div
ref={dropdownRef}
initial={{ opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'absolute',
top: pos.top,
left: pos.left,
width: pos.width,
zIndex: 9999,
}}
className="rounded-xl overflow-hidden
bg-white dark:bg-zinc-900
border border-zinc-200 dark:border-zinc-700
shadow-xl shadow-black/10 dark:shadow-black/50"
role="listbox"
>
<div className="max-h-60 overflow-y-auto">
{items.map(opt => {
const isSelected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => { onChange(opt.value); setOpen(false); }}
className={`w-full flex items-center justify-between px-3 py-2.5 text-sm text-left transition-colors duration-100
${isSelected
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10'
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'}`}
>
<span className="truncate">{opt.label}</span>
{isSelected && (
<Check size={12} strokeWidth={2.5} className="shrink-0 ml-2 opacity-70" />
)}
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}