refactor(ui): migrar componente Dropdown a HeroUI
This commit is contained in:
1 parent
cc8111275e
commit
dcab297355
1 file changed
+88
-52
@@ -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);
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => searchRef.current?.focus(), 50);
|
||||
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,13 +65,13 @@ 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={`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">
|
||||
@@ -88,35 +82,54 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{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 ${
|
||||
<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">
|
||||
<div
|
||||
onClick={(e) => removeTag(v, e as any)}
|
||||
className="hover:opacity-70 ml-1 cursor-pointer"
|
||||
>
|
||||
<FiX className="text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
{value.length > 1 && (
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-lg shrink-0 ${
|
||||
<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
|
||||
}`}
|
||||
>
|
||||
+{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>
|
||||
<FiChevronDown
|
||||
className={`shrink-0 text-sm transition-transform duration-200 ${
|
||||
open ? "rotate-180" : ""
|
||||
} ${isDark ? "text-slate-500" : "text-slate-400"}`}
|
||||
/>
|
||||
</button>
|
||||
</HeroUIPopover.Trigger>
|
||||
|
||||
{/* 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 */}
|
||||
<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"}`}>
|
||||
|
||||
<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}
|
||||
@@ -129,7 +142,11 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||
}`}
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} className={isDark ? "text-slate-500 hover:text-slate-300" : "text-slate-400 hover:text-slate-600"}>
|
||||
<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>
|
||||
)}
|
||||
@@ -137,8 +154,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
<div className={`h-px ${divider}`} />
|
||||
|
||||
{/* Options */}
|
||||
<div className="max-h-52 overflow-y-auto py-1">
|
||||
<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
|
||||
@@ -151,24 +167,40 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||
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 ${
|
||||
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"
|
||||
? 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"
|
||||
}`}
|
||||
>
|
||||
<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 ${
|
||||
<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"
|
||||
}`}>
|
||||
? 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>
|
||||
)}
|
||||
@@ -180,19 +212,23 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
{value.length > 0 && (
|
||||
<>
|
||||
<div className={`h-px ${divider}`} />
|
||||
<div className="px-4 py-2.5 flex justify-end">
|
||||
<div className={`h-px w-full ${divider}`} />
|
||||
<div className="px-4 py-2.5 flex justify-end w-full">
|
||||
<button
|
||||
type="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"}`}
|
||||
className={`text-xs font-medium transition-colors outline-none ${
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user