refactor(ui): migrar componente Dropdown a HeroUI

This commit is contained in:
Miguel Ortega committed 2026-09-13 22:18:09 -05:00
1 parent cc8111275e
commit dcab297355
1 file changed
+161 -125
+161 -125
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { FiCheck, FiChevronDown, FiSearch, FiX } from "react-icons/fi";
import { Popover as HeroUIPopover } from "@heroui/react";
import { useTheme } from "../../context/ThemeContext";
export interface DropdownOption {
@@ -27,7 +28,6 @@ export const Dropdown: React.FC<DropdownProps> = ({
const { isDark } = useTheme();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filtered = options.filter((o) =>
@@ -35,24 +35,18 @@ export const Dropdown: React.FC<DropdownProps> = ({
);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
if (open) setTimeout(() => searchRef.current?.focus(), 50);
if (!open) {
setSearch("");
return;
}
const timer = setTimeout(() => searchRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, [open]);
const toggle = (val: string) =>
onChange(value.includes(val) ? value.filter((v) => v !== val) : [...value, val]);
const removeTag = (val: string, e: React.MouseEvent) => {
const removeTag = (val: string, e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onChange(value.filter((v) => v !== val));
};
@@ -71,128 +65,170 @@ export const Dropdown: React.FC<DropdownProps> = ({
const divider = isDark ? "bg-slate-700" : "bg-slate-100";
return (
<div ref={ref} className={`relative ${className}`}>
{/* Trigger */}
<div
onClick={() => setOpen((p) => !p)}
className={`flex items-center gap-2 min-h-[46px] w-full px-4 py-2 cursor-pointer border transition-colors duration-150 ${bg} ${borderColor} ${hoverBorder} ${
open ? "rounded-t-xl border-b-0" : "rounded-xl"
}`}
>
<div className="flex-1 flex items-center gap-1.5 min-w-0 overflow-hidden">
{value.length === 0 ? (
<span className={`text-sm font-medium ${isDark ? "text-slate-500" : "text-slate-400"}`}>
{placeholder}
</span>
) : (
<>
{value.slice(0, 1).map((v) => (
<span key={v} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs font-semibold shrink-0 ${
isDark ? "bg-violet-600/25 text-violet-300" : "bg-violet-100 text-violet-700"
}`}>
{labelOf(v)}
<button type="button" onClick={(e) => removeTag(v, e)} className="hover:opacity-70">
<FiX className="text-[10px]" />
</button>
</span>
))}
{value.length > 1 && (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-lg shrink-0 ${
isDark ? "bg-slate-700 text-slate-400" : "bg-slate-100 text-slate-500"
}`}>
+{value.length - 2} más
<div className={`relative ${className}`}>
<HeroUIPopover.Root isOpen={open} onOpenChange={setOpen}>
<HeroUIPopover.Trigger className="block w-full">
<button
type="button"
className={`flex items-center gap-2 min-h-11.5 w-full px-4 py-2 cursor-pointer border transition-colors duration-150 outline-none ${bg} ${borderColor} ${hoverBorder} ${
open ? "rounded-t-xl rounded-b-none! border-b-0!" : "rounded-xl"
}`}
>
<div className="flex-1 flex items-center gap-1.5 min-w-0 overflow-hidden">
{value.length === 0 ? (
<span className={`text-sm font-medium ${isDark ? "text-slate-500" : "text-slate-400"}`}>
{placeholder}
</span>
) : (
<>
{value.slice(0, 1).map((v) => (
<span
key={v}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs font-semibold shrink-0 ${
isDark ? "bg-violet-600/25 text-violet-300" : "bg-violet-100 text-violet-700"
}`}
>
{labelOf(v)}
<div
onClick={(e) => removeTag(v, e as any)}
className="hover:opacity-70 ml-1 cursor-pointer"
>
<FiX className="text-[10px]" />
</div>
</span>
))}
{value.length > 1 && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-lg shrink-0 ${
isDark ? "bg-slate-700 text-slate-400" : "bg-slate-100 text-slate-500"
}`}
>
+{value.length - 1} más
</span>
)}
</>
)}
</>
)}
</div>
<FiChevronDown className={`shrink-0 text-sm transition-transform duration-200 ${open ? "rotate-180" : ""} ${isDark ? "text-slate-500" : "text-slate-400"}`} />
</div>
{/* Menu — absolute, sin border-top para no doblar la línea */}
{open && (
<div className={`absolute left-0 top-full z-50 w-full rounded-b-xl border border-t-0 shadow-lg overflow-hidden ${bg} ${borderColor}`}>
{/* Search */}
<div className={`h-px ${divider}`} />
<div className={`flex items-center gap-2 px-3 py-2 mx-3 my-2 rounded-lg border ${isDark ? "border-slate-700 bg-slate-900/50" : "border-slate-200 bg-slate-50"}`}>
<FiSearch className={`shrink-0 text-sm ${isDark ? "text-slate-500" : "text-slate-400"}`} />
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar..."
className={`flex-1 text-sm bg-transparent outline-none ${
isDark ? "text-slate-200 placeholder:text-slate-600" : "text-slate-700 placeholder:text-slate-400"
}`}
</div>
<FiChevronDown
className={`shrink-0 text-sm transition-transform duration-200 ${
open ? "rotate-180" : ""
} ${isDark ? "text-slate-500" : "text-slate-400"}`}
/>
{search && (
<button onClick={() => setSearch("")} className={isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"}>
<FiX className="text-xs" />
</button>
)}
</div>
</button>
</HeroUIPopover.Trigger>
<div className={`h-px ${divider}`} />
<HeroUIPopover.Content
offset={0}
placement="bottom start"
className={`w-(--trigger-width) rounded-b-xl border shadow-lg overflow-hidden p-0 m-0 ${bg} ${borderColor} rounded-t-none! border-t-0!`}
>
<HeroUIPopover.Dialog className="w-full flex flex-col p-0 outline-none">
<div className={`h-px ${divider}`} />
{/* Options */}
<div className="max-h-52 overflow-y-auto py-1">
{filtered.length === 0 ? (
<p className={`px-4 py-3 text-xs text-center ${isDark ? "text-slate-600" : "text-slate-400"}`}>
Sin resultados
</p>
) : (
filtered.map((opt) => {
const isActive = value.includes(opt.value);
return (
<div
className={`flex items-center gap-2 px-3 py-2 mx-3 my-2 rounded-lg border ${
isDark ? "border-slate-700 bg-slate-900/50" : "border-slate-200 bg-slate-50"
}`}
>
<FiSearch className={`shrink-0 text-sm ${isDark ? "text-slate-500" : "text-slate-400"}`} />
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar..."
className={`flex-1 text-sm bg-transparent outline-none ${
isDark ? "text-slate-200 placeholder:text-slate-600" : "text-slate-700 placeholder:text-slate-400"
}`}
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className={isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"}
>
<FiX className="text-xs" />
</button>
)}
</div>
<div className={`h-px ${divider}`} />
<div className="max-h-52 overflow-y-auto py-1 w-full">
{filtered.length === 0 ? (
<p className={`px-4 py-3 text-xs text-center ${isDark ? "text-slate-600" : "text-slate-400"}`}>
Sin resultados
</p>
) : (
filtered.map((opt) => {
const isActive = value.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => toggle(opt.value)}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm transition-colors text-left outline-none ${
isActive
? isDark
? "bg-violet-600/15 text-violet-300"
: "bg-violet-50 text-violet-700"
: isDark
? "text-slate-300 hover:bg-slate-700/50"
: "text-slate-700 hover:bg-slate-50"
}`}
>
<span
className={`w-4 h-4 rounded flex items-center justify-center border shrink-0 transition-colors ${
isActive
? "bg-violet-600 border-violet-600"
: isDark
? "border-slate-600"
: "border-slate-300"
}`}
>
{isActive && <FiCheck className="text-white text-[10px]" />}
</span>
<span className="flex-1 font-medium">{opt.label}</span>
{opt.count !== undefined && (
<span
className={`text-[11px] px-1.5 py-0.5 rounded font-mono shrink-0 ${
isActive
? isDark
? "bg-violet-500/30 text-violet-300"
: "bg-violet-100 text-violet-600"
: isDark
? "bg-slate-700 text-slate-500"
: "bg-slate-100 text-slate-400"
}`}
>
{opt.count}
</span>
)}
</button>
);
})
)}
</div>
{value.length > 0 && (
<>
<div className={`h-px w-full ${divider}`} />
<div className="px-4 py-2.5 flex justify-end w-full">
<button
key={opt.value}
type="button"
onClick={() => toggle(opt.value)}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm transition-colors text-left ${
isActive
? isDark ? "bg-violet-600/15 text-violet-300" : "bg-violet-50 text-violet-700"
: isDark ? "text-slate-300 hover:bg-slate-700/50" : "text-slate-700 hover:bg-slate-50"
onClick={() => onChange([])}
className={`text-xs font-medium transition-colors outline-none ${
isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"
}`}
>
<span className={`w-4 h-4 rounded flex items-center justify-center border shrink-0 transition-colors ${
isActive ? "bg-violet-600 border-violet-600" : isDark ? "border-slate-600" : "border-slate-300"
}`}>
{isActive && <FiCheck className="text-white text-[10px]" />}
</span>
<span className="flex-1 font-medium">{opt.label}</span>
{opt.count !== undefined && (
<span className={`text-[11px] px-1.5 py-0.5 rounded font-mono shrink-0 ${
isActive
? isDark ? "bg-violet-500/30 text-violet-300" : "bg-violet-100 text-violet-600"
: isDark ? "bg-slate-700 text-slate-500" : "bg-slate-100 text-slate-400"
}`}>
{opt.count}
</span>
)}
Limpiar selección
</button>
);
})
</div>
</>
)}
</div>
{value.length > 0 && (
<>
<div className={`h-px ${divider}`} />
<div className="px-4 py-2.5 flex justify-end">
<button
onClick={() => onChange([])}
className={`text-xs font-medium transition-colors ${isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"}`}
>
Limpiar selección
</button>
</div>
</>
)}
</div>
)}
</HeroUIPopover.Dialog>
</HeroUIPopover.Content>
</HeroUIPopover.Root>
</div>
);
};