refactor(ui): migrar componente Dropdown a HeroUI
This commit is contained in:
1 parent
cc8111275e
commit
dcab297355
1 file changed
+162
-126
+162
-126
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { FiCheck, FiChevronDown, FiSearch, FiX } from "react-icons/fi";
|
import { FiCheck, FiChevronDown, FiSearch, FiX } from "react-icons/fi";
|
||||||
|
import { Popover as HeroUIPopover } from "@heroui/react";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
export interface DropdownOption {
|
export interface DropdownOption {
|
||||||
@@ -27,7 +28,6 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
|||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const filtered = options.filter((o) =>
|
const filtered = options.filter((o) =>
|
||||||
@@ -35,24 +35,18 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
if (!open) {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
setSearch("");
|
||||||
setOpen(false);
|
return;
|
||||||
setSearch("");
|
}
|
||||||
}
|
const timer = setTimeout(() => searchRef.current?.focus(), 50);
|
||||||
};
|
return () => clearTimeout(timer);
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) setTimeout(() => searchRef.current?.focus(), 50);
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const toggle = (val: string) =>
|
const toggle = (val: string) =>
|
||||||
onChange(value.includes(val) ? value.filter((v) => v !== val) : [...value, val]);
|
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();
|
e.stopPropagation();
|
||||||
onChange(value.filter((v) => v !== val));
|
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";
|
const divider = isDark ? "bg-slate-700" : "bg-slate-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
|
<HeroUIPopover.Root isOpen={open} onOpenChange={setOpen}>
|
||||||
{/* Trigger */}
|
<HeroUIPopover.Trigger className="block w-full">
|
||||||
<div
|
<button
|
||||||
onClick={() => setOpen((p) => !p)}
|
type="button"
|
||||||
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} ${
|
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 border-b-0" : "rounded-xl"
|
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">
|
<div className="flex-1 flex items-center gap-1.5 min-w-0 overflow-hidden">
|
||||||
{value.length === 0 ? (
|
{value.length === 0 ? (
|
||||||
<span className={`text-sm font-medium ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
<span className={`text-sm font-medium ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||||
{placeholder}
|
{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
|
|
||||||
</span>
|
</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
|
||||||
</div>
|
className={`shrink-0 text-sm transition-transform duration-200 ${
|
||||||
<FiChevronDown className={`shrink-0 text-sm transition-transform duration-200 ${open ? "rotate-180" : ""} ${isDark ? "text-slate-500" : "text-slate-400"}`} />
|
open ? "rotate-180" : ""
|
||||||
</div>
|
} ${isDark ? "text-slate-500" : "text-slate-400"}`}
|
||||||
|
|
||||||
{/* 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"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{search && (
|
</button>
|
||||||
<button onClick={() => setSearch("")} className={isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"}>
|
</HeroUIPopover.Trigger>
|
||||||
<FiX className="text-xs" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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}`} />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Options */}
|
<div className={`h-px ${divider}`} />
|
||||||
<div className="max-h-52 overflow-y-auto py-1">
|
|
||||||
{filtered.length === 0 ? (
|
<div className="max-h-52 overflow-y-auto py-1 w-full">
|
||||||
<p className={`px-4 py-3 text-xs text-center ${isDark ? "text-slate-600" : "text-slate-400"}`}>
|
{filtered.length === 0 ? (
|
||||||
Sin resultados
|
<p className={`px-4 py-3 text-xs text-center ${isDark ? "text-slate-600" : "text-slate-400"}`}>
|
||||||
</p>
|
Sin resultados
|
||||||
) : (
|
</p>
|
||||||
filtered.map((opt) => {
|
) : (
|
||||||
const isActive = value.includes(opt.value);
|
filtered.map((opt) => {
|
||||||
return (
|
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
|
<button
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(opt.value)}
|
onClick={() => onChange([])}
|
||||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm transition-colors text-left ${
|
className={`text-xs font-medium transition-colors outline-none ${
|
||||||
isActive
|
isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"
|
||||||
? 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 ${
|
Limpiar selección
|
||||||
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>
|
</button>
|
||||||
);
|
</div>
|
||||||
})
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</HeroUIPopover.Dialog>
|
||||||
|
</HeroUIPopover.Content>
|
||||||
{value.length > 0 && (
|
</HeroUIPopover.Root>
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user