feat(ui): add portaled Select component used by lead verification modals
This commit is contained in:
@@ -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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user