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