Compare commits

..
10 Commits
11 changed files with 1366 additions and 1059 deletions

No files matched your search

+6 -6
View File
@@ -163,7 +163,7 @@ export default function ComponentsContent() {
<Badge variant="danger" icon={<FiAlertCircle />}> <Badge variant="danger" icon={<FiAlertCircle />}>
Con ícono Con ícono
</Badge> </Badge>
<Badge variant="primary" removable onRemove={() => {}}> <Badge variant="primary" removable onRemove={() => { }}>
Removable Removable
</Badge> </Badge>
</Row> </Row>
@@ -279,15 +279,15 @@ export default function ComponentsContent() {
<Row> <Row>
<Checkbox <Checkbox
label="Recordar sesión" label="Recordar sesión"
checked={checked} isSelected={checked}
onChange={(e) => setChecked(e.target.checked)} onChange={(isSelected) => setChecked(isSelected)}
/> />
<Checkbox label="Deshabilitado" disabled /> <Checkbox label="Deshabilitado" disabled />
<Checkbox <Checkbox
label="Marcado fijo" label="Marcado fijo"
checked checked
disabled disabled
onChange={() => {}} onChange={() => { }}
/> />
</Row> </Row>
<Row> <Row>
@@ -302,8 +302,8 @@ export default function ComponentsContent() {
<CodeBlock <CodeBlock
code={`<Checkbox code={`<Checkbox
label="Recordar sesión" label="Recordar sesión"
checked={checked} isSelected={checked}
onChange={e => setChecked(e.target.checked)} onChange={(isSelected) => setChecked(isSelected)}
/> />
<Switch <Switch
+140 -106
View File
@@ -1,163 +1,197 @@
"use client"; "use client";
import React from 'react';
import {
Card as HeroUICard,
CardHeader as HeroUICardHeader,
CardFooter as HeroUICardFooter,
CardProps as HeroUICardProps,
} from '@heroui/react';
import { tv } from '@heroui/styles';
const cardStyles = tv({ import React from "react";
// Desactivamos los estilos de sombra/borde base de HeroUI para tener control total import { Card as HeroUICard } from "@heroui/react";
base: 'rounded-2xl overflow-hidden transition-colors border-none shadow-none',
variants: {
customVariant: {
default: 'bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800/80 shadow-sm',
elevated: 'bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl shadow-slate-200/50 dark:shadow-black/40',
glass: 'bg-white/80 dark:bg-slate-900/60 backdrop-blur-xl border border-slate-200/70 dark:border-slate-800/70 shadow-lg',
outline: 'bg-transparent border border-slate-300 dark:border-slate-800 shadow-none',
interactive: 'bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-xl hover:shadow-cyan-500/10 hover:border-cyan-500/50 dark:hover:border-cyan-500/40 cursor-pointer transform hover:-translate-y-1 transition-all duration-300 ease-out',
},
customPadding: {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
}
},
defaultVariants: {
customVariant: 'default',
customPadding: 'none',
}
});
// 1. Omitimos las props conflictivas y declaramos tu API export type CardVariant =
export interface CardProps extends Omit<HeroUICardProps, 'className' | 'children' | 'variant'> { | "default"
variant?: 'default' | 'elevated' | 'glass' | 'outline' | 'interactive'; | "elevated"
padding?: 'none' | 'sm' | 'md' | 'lg'; | "glass"
| "outline"
| "interactive";
export type CardPadding = "none" | "sm" | "md" | "lg";
export interface CardProps
extends Omit<
React.ComponentProps<typeof HeroUICard>,
"variant" | "className"
> {
variant?: CardVariant;
padding?: CardPadding;
className?: string; className?: string;
children?: React.ReactNode;
} }
export const Card = React.forwardRef<HTMLDivElement, CardProps>( const variantStyles: Record<CardVariant, string> = {
( default:
{ "bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800/80 shadow-sm",
variant = 'default',
padding = 'none',
className = '',
children,
...props
},
ref
) => {
return (
<HeroUICard
ref={ref}
className={cardStyles({
customVariant: variant,
customPadding: padding,
className
})}
// El radius interno de HeroUI se sobrescribe usando tu 'rounded-2xl'
{...props}
>
{children}
</HeroUICard>
);
}
);
Card.displayName = 'Card';
// 2. Subcomponentes: Mapeados a HeroUI elevated:
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> { "bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl shadow-slate-200/50 dark:shadow-black/40",
glass:
"bg-white/80 dark:bg-slate-900/60 backdrop-blur-xl border border-slate-200/70 dark:border-slate-800/70 shadow-lg",
outline:
"bg-transparent border border-slate-300 dark:border-slate-800 shadow-none",
interactive:
"bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 shadow-sm " +
"hover:shadow-xl hover:shadow-cyan-500/10 " +
"hover:border-cyan-500/50 dark:hover:border-cyan-500/40 " +
"cursor-pointer transform hover:-translate-y-1 " +
"transition-all duration-300 ease-out",
};
const paddingStyles: Record<CardPadding, string> = {
none: "p-0",
sm: "p-4",
md: "p-6",
lg: "p-8",
};
export const Card = ({
variant = "default",
padding = "none",
className = "",
children,
...props
}: CardProps) => {
return (
<HeroUICard
{...props}
className={[
"rounded-2xl overflow-hidden transition-colors",
variantStyles[variant],
paddingStyles[padding],
className,
]
.filter(Boolean)
.join(" ")}
>
{children}
</HeroUICard>
);
};
export interface CardHeaderProps
extends React.ComponentProps<typeof HeroUICard.Header> {
bordered?: boolean; bordered?: boolean;
} }
export const CardHeader: React.FC<CardHeaderProps> = ({ export const CardHeader = ({
bordered = false, bordered = false,
className = '', className = "",
children, children,
...props ...props
}) => { }: CardHeaderProps) => {
return ( return (
<HeroUICardHeader <HeroUICard.Header
className={`
p-5 sm:p-6 flex flex-col space-y-1.5
${bordered ? 'border-b border-slate-100 dark:border-slate-800/80' : ''}
${className}
`}
{...props} {...props}
className={[
"p-5 sm:p-6 flex flex-col space-y-1.5",
bordered
? "border-b border-slate-100 dark:border-slate-800/80"
: "",
className,
]
.filter(Boolean)
.join(" ")}
> >
{children} {children}
</HeroUICardHeader> </HeroUICard.Header>
); );
}; };
// 3. CardTitle y CardDescription: Se mantienen como HTML estándar export const CardTitle = ({
export const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({ className = "",
className = '',
children, children,
...props ...props
}) => { }: React.ComponentProps<typeof HeroUICard.Title>) => {
return ( return (
<h3 <HeroUICard.Title
className={`text-lg sm:text-xl font-bold tracking-tight text-slate-900 dark:text-white transition-colors ${className}`}
{...props} {...props}
className={[
"text-lg sm:text-xl font-bold tracking-tight",
"text-slate-900 dark:text-white",
"transition-colors",
className,
]
.filter(Boolean)
.join(" ")}
> >
{children} {children}
</h3> </HeroUICard.Title>
); );
}; };
export const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({ export const CardDescription = ({
className = '', className = "",
children, children,
...props ...props
}) => { }: React.ComponentProps<typeof HeroUICard.Description>) => {
return ( return (
<p <HeroUICard.Description
className={`text-xs sm:text-sm text-slate-500 dark:text-slate-400 leading-relaxed ${className}`}
{...props} {...props}
className={[
"text-xs sm:text-sm",
"text-slate-500 dark:text-slate-400",
"leading-relaxed",
className,
]
.filter(Boolean)
.join(" ")}
> >
{children} {children}
</p> </HeroUICard.Description>
); );
}; };
// 4. CardContent: Mapeado a CardBody de HeroUI export const CardContent = ({
export const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className = "",
className = '',
children, children,
...props ...props
}) => { }: React.ComponentProps<typeof HeroUICard.Content>) => {
return ( return (
<div className={`p-5 sm:p-6 overflow-visible ${className}`} {...props}> <HeroUICard.Content
{...props}
className={[
"p-5 sm:p-6",
className,
]
.filter(Boolean)
.join(" ")}
>
{children} {children}
</div> </HeroUICard.Content>
); );
}; };
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> { export interface CardFooterProps
extends React.ComponentProps<typeof HeroUICard.Footer> {
bordered?: boolean; bordered?: boolean;
} }
export const CardFooter: React.FC<CardFooterProps> = ({ export const CardFooter = ({
bordered = false, bordered = false,
className = '', className = "",
children, children,
...props ...props
}) => { }: CardFooterProps) => {
return ( return (
<HeroUICardFooter <HeroUICard.Footer
className={`
p-5 sm:p-6 flex items-center justify-between
${bordered ? 'border-t border-slate-100 dark:border-slate-800/80' : ''}
${className}
`}
{...props} {...props}
className={[
"p-5 sm:p-6 flex items-center justify-between",
bordered
? "border-t border-slate-100 dark:border-slate-800/80"
: "",
className,
]
.filter(Boolean)
.join(" ")}
> >
{children} {children}
</HeroUICardFooter> </HeroUICard.Footer>
); );
}; };
+132 -96
View File
@@ -1,112 +1,148 @@
"use client"; "use client";
import React, { useEffect, useRef } from 'react';
import { FiCheck, FiMinus } from 'react-icons/fi';
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> { import React from "react";
import {
Checkbox as HeroUICheckbox,
Description,
FieldError,
} from "@heroui/react";
import { FiCheck, FiMinus } from "react-icons/fi";
export interface CheckboxProps
extends Omit<
React.ComponentProps<typeof HeroUICheckbox>,
"children" | "defaultSelected" | "isDisabled" | "isInvalid" | "isIndeterminate"
> {
label?: React.ReactNode; label?: React.ReactNode;
description?: string; description?: string;
errorMessage?: string; errorMessage?: string;
checked?: boolean;
disabled?: boolean;
indeterminate?: boolean; indeterminate?: boolean;
className?: string;
} }
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>( export const Checkbox = ({
( label,
{ description,
label, errorMessage,
description, indeterminate = false,
errorMessage, checked,
indeterminate = false, isSelected,
checked, disabled = false,
disabled, className = "",
className = '', id,
id, name,
name, onChange,
...props ...props
}, }: CheckboxProps) => {
forwardedRef return (
) => { <HeroUICheckbox
const internalRef = useRef<HTMLInputElement>(null); {...props}
const resolvedRef = (forwardedRef as React.RefObject<HTMLInputElement>) || internalRef; id={id}
const checkboxId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined); name={name}
isSelected={checked}
isDisabled={disabled}
isIndeterminate={indeterminate}
isInvalid={Boolean(errorMessage)}
onChange={onChange}
className={className}
>
<HeroUICheckbox.Content className="items-start gap-2.5">
<HeroUICheckbox.Control
className={[
"mt-0.5 shrink-0 size-4 rounded-md",
"border",
"transition-all duration-200",
useEffect(() => { // Estado normal
if (resolvedRef.current) { "bg-white dark:bg-slate-900",
resolvedRef.current.indeterminate = indeterminate; "border-slate-300 dark:border-slate-700",
}
}, [indeterminate, resolvedRef]);
const isChecked = Boolean(checked); // Hover
"group-hover:border-slate-400",
"dark:group-hover:border-slate-500",
return ( // Focus
<div className={`flex flex-col space-y-1 ${className}`}> "data-[focus-visible=true]:ring-2",
<label "data-[focus-visible=true]:ring-cyan-500",
htmlFor={checkboxId} "data-[focus-visible=true]:ring-offset-2",
className={`inline-flex items-start gap-2.5 select-none cursor-pointer group ${ "data-[focus-visible=true]:ring-offset-white",
disabled ? 'opacity-50 !cursor-not-allowed' : '' "dark:data-[focus-visible=true]:ring-offset-slate-950",
}`}
// Seleccionado
"data-[selected=true]:bg-cyan-500",
"data-[selected=true]:border-cyan-500",
"data-[selected=true]:text-slate-950",
"data-[selected=true]:shadow-sm",
"data-[selected=true]:shadow-cyan-500/20",
// Indeterminado
"data-[indeterminate=true]:bg-cyan-500",
"data-[indeterminate=true]:border-cyan-500",
"data-[indeterminate=true]:text-slate-950",
// Error
errorMessage
? "data-[invalid=true]:border-rose-500"
: "",
]
.filter(Boolean)
.join(" ")}
> >
{/* Custom Styled Box */} <HeroUICheckbox.Indicator>
<div className="relative flex items-center justify-center mt-0.5 shrink-0"> {({ isSelected, isIndeterminate }) => {
<input if (isIndeterminate) {
ref={resolvedRef} return <FiMinus className="text-xs stroke-3" />;
id={checkboxId} }
name={name}
type="checkbox"
checked={checked}
disabled={disabled}
className="peer sr-only"
{...props}
/>
<div if (isSelected) {
className={` return <FiCheck className="text-xs stroke-3" />;
w-4 h-4 rounded-md flex items-center justify-center transition-all duration-200 }
border
peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 return null;
peer-focus-visible:ring-cyan-500 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-950 }}
${ </HeroUICheckbox.Indicator>
isChecked || indeterminate </HeroUICheckbox.Control>
? 'bg-cyan-500 border-cyan-500 text-slate-950 shadow-sm shadow-cyan-500/20'
: 'bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-transparent group-hover:border-slate-400 dark:group-hover:border-slate-500' {(label || description) && (
} <div className="flex flex-col text-left">
${ {label && (
errorMessage <span
? '!border-rose-500 !bg-rose-50 dark:!bg-rose-950/30' className="
: '' text-xs sm:text-sm font-medium
} text-slate-800 dark:text-slate-200
`} group-hover:text-slate-950
> dark:group-hover:text-white
{indeterminate ? ( transition-colors
<FiMinus className="text-xs stroke-3" /> "
) : isChecked ? ( >
<FiCheck className="text-xs stroke-3" /> {label}
) : null} </span>
</div> )}
{description && (
<Description
className="
text-xs
text-slate-500 dark:text-slate-400
"
>
{description}
</Description>
)}
</div> </div>
{/* Label and Sub-description */}
{(label || description) && (
<div className="flex flex-col text-left">
{label && (
<span className="text-xs sm:text-sm font-medium text-slate-800 dark:text-slate-200 group-hover:text-slate-950 dark:group-hover:text-white transition-colors">
{label}
</span>
)}
{description && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{description}
</span>
)}
</div>
)}
</label>
{errorMessage && (
<p className="text-xs text-rose-600 dark:text-rose-400 pl-6.5">{errorMessage}</p>
)} )}
</div> </HeroUICheckbox.Content>
);
}
);
Checkbox.displayName = 'Checkbox'; {errorMessage && (
<FieldError className="pl-6.5 text-xs">
{errorMessage}
</FieldError>
)}
</HeroUICheckbox>
);
};
Checkbox.displayName = "Checkbox";
+333 -267
View File
@@ -1,289 +1,355 @@
"use client"; "use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { FiCalendar, FiChevronLeft, FiChevronRight, FiX } from "react-icons/fi";
interface DatePickerProps { import React, { useMemo } from "react";
import {
DatePicker as HeroUIDatePicker,
DateField as HeroUIDateField,
Calendar as HeroUICalendar,
Label,
FieldError,
} from "@heroui/react";
import type { DateValue } from "@internationalized/date";
import { parseDate as parseInternationalDate } from "@internationalized/date";
import {
FiCalendar,
FiChevronLeft,
FiChevronRight,
FiX,
} from "react-icons/fi";
export interface DatePickerProps {
label?: string; label?: string;
value?: string; // "YYYY-MM-DD" value?: string; //Fecha en formato YYYY-MM-DD o undefined cuando se limpia
onChange: (value: string | undefined) => void; onChange: (value: string | undefined) => void; //Devuelve la fecha en formato YYYY-MM-DD o undefined cuando se limpia.
placeholder?: string; placeholder?: string;
required?: boolean; required?: boolean;
maxDate?: Date; maxDate?: Date; //Fecha máxima permitida.
className?: string;
errorMessage?: string;
} }
const MONTHS = [ function toCalendarDate(dateStr?: string): DateValue | undefined {
"Enero","Febrero","Marzo","Abril","Mayo","Junio", if (!dateStr) {
"Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre", return undefined;
]; }
const DAYS = ["Do","Lu","Ma","Mi","Ju","Vi","Sa"];
const DROPDOWN_H = 320; // px estimado del dropdown
function parseDate(value?: string): Date | undefined { try {
if (!value) return undefined; return parseInternationalDate(dateStr);
const d = new Date(value + "T12:00:00"); } catch {
return isNaN(d.getTime()) ? undefined : d; return undefined;
}
} }
function formatDisplay(value?: string): string { function toDateString(date: DateValue): string {
const d = parseDate(value); const yyyy = date.year;
if (!d) return ""; const mm = String(date.month).padStart(2, "0");
return d.toLocaleDateString("es-MX", { day: "numeric", month: "long", year: "numeric" }); const dd = String(date.day).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
} }
export function DatePicker({ export const DatePicker = React.forwardRef<
label, HTMLDivElement,
value, DatePickerProps
onChange, >(
placeholder = "Selecciona una fecha", (
required, {
maxDate = new Date(), label,
}: DatePickerProps) { value,
const selected = parseDate(value); onChange,
const [open, setOpen] = useState(false); placeholder,
const [view, setView] = useState<"days" | "months" | "years">("days"); required = false,
const [cursor, setCursor] = useState(() => { maxDate,
const d = selected ?? new Date(); className = "",
return { month: d.getMonth(), year: d.getFullYear() }; errorMessage,
}); },
const [pos, setPos] = useState({ top: 0, left: 0, width: 0, openUp: false }); ref
) => {
const dateValue = useMemo(
() => toCalendarDate(value),
[value]
);
const triggerRef = useRef<HTMLButtonElement>(null); const maxDateValue = useMemo<DateValue | undefined>(() => {
const dropdownRef = useRef<HTMLDivElement>(null); if (!maxDate) {
return undefined;
}
const calcPos = useCallback(() => { try {
if (!triggerRef.current) return; const yyyy = maxDate.getFullYear();
const r = triggerRef.current.getBoundingClientRect(); const mm = String(maxDate.getMonth() + 1).padStart(2, "0");
const spaceBelow = window.innerHeight - r.bottom; const dd = String(maxDate.getDate()).padStart(2, "0");
const openUp = spaceBelow < DROPDOWN_H + 8 && r.top > DROPDOWN_H + 8;
setPos({
top: openUp ? r.top - 8 : r.bottom + 8,
left: r.left,
width: r.width,
openUp,
});
}, []);
const openPicker = () => { return parseInternationalDate(`${yyyy}-${mm}-${dd}`);
if (selected) setCursor({ month: selected.getMonth(), year: selected.getFullYear() }); } catch {
setView("days"); return undefined;
calcPos(); }
setOpen(true); }, [maxDate]);
};
useEffect(() => { const handleChange = (newDate: DateValue | null) => {
if (!open) return; if (newDate) {
const onScroll = () => calcPos(); onChange(toDateString(newDate));
const onResize = () => calcPos(); } else {
const onMouse = (e: MouseEvent) => { onChange(undefined);
if ( }
triggerRef.current?.contains(e.target as Node) ||
dropdownRef.current?.contains(e.target as Node)
) return;
setOpen(false);
}; };
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize); const handleClear = () => {
document.addEventListener("mousedown", onMouse); onChange(undefined);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
document.removeEventListener("mousedown", onMouse);
}; };
}, [open, calcPos]);
const selectDay = (day: number) => { return (
const d = new Date(cursor.year, cursor.month, day); <HeroUIDatePicker.Root
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; ref={ref}
onChange(iso); value={dateValue ?? null}
setOpen(false); onChange={handleChange}
}; maxValue={maxDateValue}
isRequired={required}
isInvalid={Boolean(errorMessage)}
className={`w-full flex flex-col gap-1.5 ${className}`}
>
{/* Label */}
{label && (
<Label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
{label}
const daysInMonth = new Date(cursor.year, cursor.month + 1, 0).getDate(); {required && (
const firstDow = new Date(cursor.year, cursor.month, 1).getDay(); <span className="ml-1 font-bold text-rose-500">
const today = new Date(); *
</span>
const isDisabled = (day: number) => new Date(cursor.year, cursor.month, day) > maxDate; )}
const isSelected = (day: number) => </Label>
selected?.getFullYear() === cursor.year &&
selected?.getMonth() === cursor.month &&
selected?.getDate() === day;
const isToday = (day: number) =>
today.getFullYear() === cursor.year &&
today.getMonth() === cursor.month &&
today.getDate() === day;
const yearRange = Array.from({ length: 101 }, (_, i) => today.getFullYear() - 100 + i).reverse();
const dropdown = open ? (
<div
ref={dropdownRef}
style={{
position: "fixed",
top: pos.openUp ? "auto" : pos.top,
bottom: pos.openUp ? window.innerHeight - pos.top : "auto",
left: pos.left,
width: Math.max(pos.width, 288),
zIndex: 9999,
}}
className="rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-800">
<button
type="button"
onClick={() => view === "days" && setCursor(c => {
const d = new Date(c.year, c.month - 1, 1);
return { month: d.getMonth(), year: d.getFullYear() };
})}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition text-slate-500 dark:text-slate-400"
>
<FiChevronLeft />
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => setView(v => v === "months" ? "days" : "months")}
className="text-sm font-semibold text-slate-800 dark:text-slate-100 hover:text-violet-600 dark:hover:text-violet-400 transition"
>
{MONTHS[cursor.month]}
</button>
<button
type="button"
onClick={() => setView(v => v === "years" ? "days" : "years")}
className="text-sm font-semibold text-slate-800 dark:text-slate-100 hover:text-violet-600 dark:hover:text-violet-400 transition"
>
{cursor.year}
</button>
</div>
<button
type="button"
onClick={() => view === "days" && setCursor(c => {
const d = new Date(c.year, c.month + 1, 1);
return { month: d.getMonth(), year: d.getFullYear() };
})}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition text-slate-500 dark:text-slate-400"
>
<FiChevronRight />
</button>
</div>
{/* Days */}
{view === "days" && (
<div className="p-3">
<div className="grid grid-cols-7 mb-1">
{DAYS.map(d => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-400 dark:text-slate-500 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5">
{Array.from({ length: firstDow }).map((_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
disabled={isDisabled(day)}
onClick={() => selectDay(day)}
className={`
aspect-square w-full flex items-center justify-center rounded-lg text-sm transition-all
${isSelected(day)
? "bg-violet-600 text-white font-semibold"
: isToday(day)
? "border border-violet-400 text-violet-600 dark:text-violet-400 font-semibold"
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"}
${isDisabled(day) ? "opacity-30 cursor-not-allowed" : ""}
`}
>
{day}
</button>
))}
</div>
</div>
)}
{/* Months */}
{view === "months" && (
<div className="grid grid-cols-3 gap-2 p-3">
{MONTHS.map((m, i) => (
<button
key={m}
type="button"
onClick={() => { setCursor(c => ({ ...c, month: i })); setView("days"); }}
className={`py-2 rounded-xl text-sm font-medium transition ${
cursor.month === i
? "bg-violet-600 text-white"
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
>
{m.slice(0, 3)}
</button>
))}
</div>
)}
{/* Years */}
{view === "years" && (
<div className="h-52 overflow-y-auto p-2 grid grid-cols-3 gap-1 content-start">
{yearRange.map(y => (
<button
key={y}
type="button"
onClick={() => { setCursor(c => ({ ...c, year: y })); setView("days"); }}
className={`py-2 rounded-xl text-sm font-medium transition ${
cursor.year === y
? "bg-violet-600 text-white"
: "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
>
{y}
</button>
))}
</div>
)}
</div>
) : null;
return (
<div className="w-full space-y-1.5">
{label && (
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
)}
<div className="relative flex items-center">
<div className="absolute left-3.5 pointer-events-none text-slate-400 dark:text-slate-500">
<FiCalendar className="text-base" />
</div>
<button
ref={triggerRef}
type="button"
onClick={openPicker}
className={`
w-full pl-10 pr-10 py-2.5 text-sm rounded-xl text-left transition-all duration-200 outline-none
bg-white dark:bg-slate-900/90
border border-slate-300 dark:border-slate-800
hover:border-slate-400 dark:hover:border-slate-700
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
${value ? "text-slate-900 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}
`}
>
{value ? formatDisplay(value) : placeholder}
</button>
{value && (
<button
type="button"
onClick={() => onChange(undefined)}
className="absolute right-3 p-1 rounded-md text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<FiX className="text-sm" />
</button>
)} )}
</div>
{typeof window !== "undefined" && createPortal(dropdown, document.body)} {/* Campo de fecha */}
</div> <HeroUIDateField.Group
); className={[
} "bg-white dark:bg-slate-900/90",
"border border-slate-300 dark:border-slate-800",
"hover:border-slate-400 dark:hover:border-slate-700",
"data-[focus-within=true]:border-cyan-500",
"data-[focus-within=true]:ring-4",
"data-[focus-within=true]:ring-cyan-500/15",
"rounded-xl flex items-center shadow-sm",
"transition-all duration-200 h-10",
errorMessage
? "border-rose-500 dark:border-rose-500"
: "",
].join(" ")}
>
{/* Icono izquierdo */}
<HeroUIDateField.Prefix
className="pl-3.5 pr-1.5 text-slate-400 dark:text-slate-500"
>
<FiCalendar className="text-base" />
</HeroUIDateField.Prefix>
{/* Input segmentado */}
<HeroUIDateField.Input
className="flex flex-1 items-center text-sm text-slate-900 outline-none dark:text-slate-100"
>
{(segment) => (
<HeroUIDateField.Segment
segment={segment}
className={[
"min-w-5 rounded px-0.5 text-center outline-none",
"data-[placeholder=true]:text-slate-400",
"dark:data-[placeholder=true]:text-slate-500",
"focus:bg-cyan-500/20",
"focus:text-cyan-700",
"dark:focus:text-cyan-400",
].join(" ")}
/>
)}
</HeroUIDateField.Input>
{!value && placeholder && (
<span className="pointer-events-none absolute ml-10 text-sm text-slate-400 dark:text-slate-500">
{placeholder}
</span>
)}
<HeroUIDateField.Suffix className="flex items-center">
{/* Botón limpiar */}
{value && (
<button
type="button"
onClick={handleClear}
aria-label="Limpiar fecha"
className={[
"mr-1 rounded-md p-1",
"text-slate-400",
"hover:bg-slate-100 hover:text-slate-700",
"dark:hover:bg-slate-800 dark:hover:text-slate-200",
"outline-none transition",
].join(" ")}
>
<FiX className="text-sm" />
</button>
)}
{/* Botón calendario */}
<HeroUIDatePicker.Trigger
className={[
"rounded-r-xl pl-1.5 pr-3",
"text-slate-400",
"hover:text-slate-700",
"dark:hover:text-slate-200",
"outline-none",
"focus-visible:text-cyan-500",
].join(" ")}
>
<HeroUIDatePicker.TriggerIndicator />
</HeroUIDatePicker.Trigger>
</HeroUIDateField.Suffix>
</HeroUIDateField.Group>
{/* Error */}
{errorMessage && (
<FieldError className="text-xs text-rose-500">
{errorMessage}
</FieldError>
)}
{/* Calendario */}
<HeroUIDatePicker.Popover
className={[
"min-w-[288px]",
"overflow-hidden rounded-2xl",
"border border-slate-200 dark:border-slate-700",
"bg-white dark:bg-slate-900",
"shadow-2xl",
"outline-none",
].join(" ")}
>
<HeroUICalendar.Root aria-label={label ?? "Seleccionar fecha"}>
{/* Header */}
<HeroUICalendar.Header
className={[
"flex items-center justify-between",
"border-b border-slate-100 dark:border-slate-800",
"px-4 py-3",
].join(" ")}
>
{/* Mes anterior */}
<HeroUICalendar.NavButton
slot="previous"
className={[
"rounded-lg p-1.5",
"text-slate-500 dark:text-slate-400",
"hover:bg-slate-100 dark:hover:bg-slate-800",
"transition",
"outline-none",
"focus-visible:ring-2 focus-visible:ring-cyan-500",
].join(" ")}
>
<FiChevronLeft className="text-lg" />
</HeroUICalendar.NavButton>
{/* Mes / año */}
<HeroUICalendar.Heading
className={[
"text-sm font-semibold",
"text-slate-800 dark:text-slate-100",
].join(" ")}
/>
{/* Mes siguiente */}
<HeroUICalendar.NavButton
slot="next"
className={[
"rounded-lg p-1.5",
"text-slate-500 dark:text-slate-400",
"hover:bg-slate-100 dark:hover:bg-slate-800",
"transition",
"outline-none",
"focus-visible:ring-2 focus-visible:ring-cyan-500",
].join(" ")}
>
<FiChevronRight className="text-lg" />
</HeroUICalendar.NavButton>
</HeroUICalendar.Header>
{/* Grid del calendario */}
<div className="p-3">
<HeroUICalendar.Grid className="w-full border-collapse">
{/* Días de la semana */}
<HeroUICalendar.GridHeader>
{(day) => (
<HeroUICalendar.HeaderCell
className={[
"pb-2 text-center",
"text-[10px] font-semibold",
"text-slate-400 dark:text-slate-500",
].join(" ")}
>
{day}
</HeroUICalendar.HeaderCell>
)}
</HeroUICalendar.GridHeader>
{/* Días */}
<HeroUICalendar.GridBody>
{(date) => (
<HeroUICalendar.Cell
date={date}
className="p-0.5 outline-none"
>
{({
formattedDate,
isSelected,
isDisabled,
isToday,
}) => (
<HeroUICalendar.CellIndicator
className={[
"flex aspect-square w-full min-w-8",
"items-center justify-center",
"rounded-lg text-sm transition-all",
"outline-none",
"focus-visible:ring-2",
"focus-visible:ring-cyan-500",
"focus-visible:ring-offset-1",
"focus-visible:ring-offset-white",
"dark:focus-visible:ring-offset-slate-900",
isSelected
? "bg-violet-600 font-semibold text-white shadow-sm"
: isToday
? [
"border border-violet-400",
"font-semibold text-violet-600",
"hover:bg-slate-100",
"dark:text-violet-400",
"dark:hover:bg-slate-800",
].join(" ")
: [
"text-slate-700",
"hover:bg-slate-100",
"dark:text-slate-300",
"dark:hover:bg-slate-800",
].join(" "),
isDisabled
? "cursor-not-allowed opacity-30 hover:bg-transparent"
: "",
].join(" ")}
>
{formattedDate}
</HeroUICalendar.CellIndicator>
)}
</HeroUICalendar.Cell>
)}
</HeroUICalendar.GridBody>
</HeroUICalendar.Grid>
</div>
</HeroUICalendar.Root>
</HeroUIDatePicker.Popover>
</HeroUIDatePicker.Root>
);
}
);
DatePicker.displayName = "DatePicker";
+162 -126
View File
@@ -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>
); );
}; };
+142 -28
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { FiUpload, FiX } from "react-icons/fi"; import { FiUpload, FiX } from "react-icons/fi";
import { Button, CloseButton, Label } from "@heroui/react";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
export interface ImageUploadProps { export interface ImageUploadProps {
@@ -11,8 +13,15 @@ export interface ImageUploadProps {
hint?: string; hint?: string;
} }
export function ImageUpload({ label, value, onChange, aspectRatio = "square", hint }: ImageUploadProps) { export function ImageUpload({
label,
value,
onChange,
aspectRatio = "square",
hint,
}: ImageUploadProps) {
const { isDark } = useTheme(); const { isDark } = useTheme();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@@ -20,34 +29,118 @@ export function ImageUpload({ label, value, onChange, aspectRatio = "square", hi
const handleFile = (file: File) => { const handleFile = (file: File) => {
if (!file.type.startsWith("image/")) return; if (!file.type.startsWith("image/")) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => onChange(e.target?.result as string);
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result === "string") {
onChange(result);
}
};
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); event.preventDefault();
setDragging(false); setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file); const file = event.dataTransfer.files[0];
if (file) {
handleFile(file);
}
}; };
const border = isDark ? "border-slate-700" : "border-slate-200"; const borderColor = isDark
const bg = isDark ? "bg-slate-800/50" : "bg-slate-50"; ? "border-slate-700"
const active = dragging ? (isDark ? "border-violet-500 bg-violet-500/10" : "border-violet-400 bg-violet-50") : ""; : "border-slate-200";
const muted = isDark ? "text-slate-400" : "text-slate-500";
const backgroundColor = isDark
? "bg-slate-800/50"
: "bg-slate-50";
const dragActiveStyles = dragging
? isDark
? "border-violet-500 bg-violet-500/10"
: "border-violet-400 bg-violet-50"
: "";
const mutedColor = isDark
? "text-slate-400"
: "text-slate-500";
const labelColor = isDark
? "text-slate-300"
: "text-slate-700";
const pickerSize = isBanner
? "h-32 w-full"
: "h-28 w-28";
const openFilePicker = () => {
inputRef.current?.click();
};
const handleInputChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
};
const handleRemove = () => {
onChange(undefined);
// Permite volver a seleccionar la misma imagen
// después de eliminarla.
if (inputRef.current) {
inputRef.current.value = "";
}
};
return ( return (
<div> <div>
<label className={`block text-sm font-medium mb-1.5 ${isDark ? "text-slate-300" : "text-slate-700"}`}> <Label
className={`mb-1.5 block text-sm font-medium ${labelColor}`}
>
{label} {label}
</label> </Label>
<div <div
className={`relative rounded-xl border-2 border-dashed transition-all duration-200 overflow-hidden cursor-pointer ${border} ${bg} ${active} ${isBanner ? "h-32" : "h-28 w-28"}`} role="button"
onClick={() => inputRef.current?.click()} tabIndex={0}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }} aria-label={`Seleccionar imagen para ${label}`}
onDragLeave={() => setDragging(false)} className={[
"relative cursor-pointer overflow-hidden rounded-xl",
"border-2 border-dashed",
"transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-violet-500",
borderColor,
backgroundColor,
dragActiveStyles,
pickerSize,
].join(" ")}
onClick={openFilePicker}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openFilePicker();
}
}}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => {
setDragging(false);
}}
onDrop={handleDrop} onDrop={handleDrop}
> >
{value ? ( {value ? (
@@ -55,34 +148,55 @@ export function ImageUpload({ label, value, onChange, aspectRatio = "square", hi
<img <img
src={value} src={value}
alt={label} alt={label}
className="w-full h-full object-cover" className="h-full w-full object-cover"
/> />
<button
type="button" <div
onClick={(e) => { e.stopPropagation(); onChange(undefined); }} className="absolute right-2 top-2"
className="absolute top-2 right-2 p-1 rounded-full bg-black/60 text-white hover:bg-black/80 transition" onClick={(event) => event.stopPropagation()}
> >
<FiX className="text-xs" /> <CloseButton
</button> aria-label="Eliminar imagen"
onPress={handleRemove}
className="rounded-full bg-black/60 text-white hover:bg-black/80"
>
<FiX className="text-xs" />
</CloseButton>
</div>
</> </>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full gap-1.5 px-4 text-center"> <div className="flex h-full flex-col items-center justify-center gap-1.5 px-4 text-center">
<FiUpload className={`text-xl ${muted}`} /> <FiUpload className={`text-xl ${mutedColor}`} />
<p className={`text-xs ${muted}`}>
<p className={`text-xs ${mutedColor}`}>
{dragging ? "Suelta aquí" : "Click o arrastra"} {dragging ? "Suelta aquí" : "Click o arrastra"}
</p> </p>
<Button
type="button"
variant="ghost"
size="sm"
onPress={openFilePicker}
className="mt-1"
>
Seleccionar
</Button>
</div> </div>
)} )}
</div> </div>
{hint && <p className={`text-[11px] mt-1 ${muted}`}>{hint}</p>} {hint && (
<p className={`mt-1 text-[11px] ${mutedColor}`}>
{hint}
</p>
)}
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/*" accept="image/*"
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} onChange={handleInputChange}
/> />
</div> </div>
); );
+118 -124
View File
@@ -1,8 +1,9 @@
"use client"; import React, { useState } from "react";
import React, { useState } from 'react';
import { FiEye, FiEyeOff, FiX, FiAlertCircle } from 'react-icons/fi';
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> { import { Input as HeroInput, TextField, Label, Description, FieldError, InputGroup } from "@heroui/react";
import { FiEye, FiEyeOff, FiX, FiAlertCircle } from "react-icons/fi";
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string; label?: string;
helperText?: string; helperText?: string;
errorMessage?: string; errorMessage?: string;
@@ -10,28 +11,28 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
rightIcon?: React.ReactNode; rightIcon?: React.ReactNode;
isClearable?: boolean; isClearable?: boolean;
onClear?: () => void; onClear?: () => void;
sizeVariant?: 'sm' | 'md' | 'lg'; sizeVariant?: "sm" | "md" | "lg";
fullWidth?: boolean; fullWidth?: boolean;
} }
const sizeStyles = { const sizeStyles = {
sm: { sm: {
input: 'py-2 text-xs rounded-lg', input: "py-2 text-xs rounded-lg",
icon: 'text-sm', icon: "text-sm",
leftPad: 'pl-8', leftPad: "pl-8",
rightPad: 'pr-8', rightPad: "pr-8",
}, },
md: { md: {
input: 'py-2.5 text-sm rounded-xl', input: "py-2.5 text-sm rounded-xl",
icon: 'text-base', icon: "text-base",
leftPad: 'pl-10', leftPad: "pl-10",
rightPad: 'pr-10', rightPad: "pr-10",
}, },
lg: { lg: {
input: 'py-3.5 text-base rounded-2xl', input: "py-3.5 text-base rounded-2xl",
icon: 'text-lg', icon: "text-lg",
leftPad: 'pl-12', leftPad: "pl-12",
rightPad: 'pr-12', rightPad: "pr-12",
}, },
}; };
@@ -45,145 +46,138 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
rightIcon, rightIcon,
isClearable = false, isClearable = false,
onClear, onClear,
sizeVariant = 'md', sizeVariant = "md",
fullWidth = true, fullWidth = true,
type = 'text', type = "text",
id, id,
name, name,
disabled, disabled,
required, required,
className = '', className = "",
value, value,
...props ...props
}, },
ref ref,
) => { ) => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const inputId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined); const inputId = id || name || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined);
const isPasswordType = type === "password";
const isPasswordType = type === 'password'; const computedType = isPasswordType ? (showPassword ? "text" : "password") : type;
const computedType = isPasswordType ? (showPassword ? 'text' : 'password') : type;
const hasError = Boolean(errorMessage); const hasError = Boolean(errorMessage);
const hasValue = value !== undefined && value !== '' && value !== null; const hasValue = value !== undefined && value !== "" && value !== null;
const currentSize = sizeStyles[sizeVariant]; const currentSize = sizeStyles[sizeVariant];
return ( return (
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}> <TextField
id={`${inputId}-field`}
isInvalid={hasError}
isDisabled={disabled}
isRequired={required}
className={`${fullWidth ? "w-full" : "inline-block"} space-y-1.5`}>
{/* Label */} {/* Label */}
{label && ( {label && (
<div className="flex items-center justify-between"> <Label
<label htmlFor={inputId}
htmlFor={inputId} className={`block text-xs font-semibold select-none transition-colors ${
className={`block text-xs font-semibold select-none transition-colors ${ hasError ? "text-rose-600 dark:text-rose-400" : "text-slate-700 dark:text-slate-300"
hasError }`}>
? 'text-rose-600 dark:text-rose-400' {label}
: 'text-slate-700 dark:text-slate-300' {required}
}`} </Label>
>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
</div>
)} )}
{/* Input Wrapper */} {/* Input Wrapper */}
<div className="relative flex items-center"> <div className="relative flex items-center w-full">
{/* Left Icon */} <InputGroup
{leftIcon && (
<div
className={`absolute left-3.5 pointer-events-none flex items-center justify-center transition-colors ${
hasError
? 'text-rose-500'
: 'text-slate-400 dark:text-slate-500'
} ${currentSize.icon}`}
>
{leftIcon}
</div>
)}
{/* Core Input Element */}
<input
ref={ref}
id={inputId}
name={name}
type={computedType}
value={value}
disabled={disabled}
required={required}
className={` className={`
w-full px-3.5 font-normal transition-all duration-200 outline-none flex items-center w-full overflow-hidden transition-all duration-200 outline-none
bg-white text-slate-900 placeholder:text-slate-400 bg-white border border-slate-300 hover:border-slate-400
border border-slate-300 focus-within:border-cyan-500 focus-within:ring-4 focus-within:ring-cyan-500/15
hover:border-slate-400 dark:bg-slate-900/90 dark:border-slate-800 dark:hover:border-slate-700
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15 dark:focus-within:border-cyan-500 dark:focus-within:ring-cyan-500/25
dark:bg-slate-900/90 dark:text-slate-100 dark:placeholder:text-slate-500 ${disabled ? "opacity-60 bg-slate-100 dark:bg-slate-950 cursor-not-allowed" : ""}
dark:border-slate-800 dark:hover:border-slate-700 ${hasError ? "border-rose-500! ring-rose-500/20! focus-within:border-rose-500! focus-within:ring-rose-500/20!" : ""}
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25 ${currentSize.input} `}>
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
${leftIcon ? currentSize.leftPad : ''}
${isPasswordType || isClearable || rightIcon ? currentSize.rightPad : ''}
${
hasError
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
: ''
}
${currentSize.input}
${className}
`}
{...props}
/>
{/* Right Action Icons (Clear, Password Toggle, Custom RightIcon) */} {/* Left Icon */}
<div className="absolute right-3 flex items-center gap-1.5 text-slate-400 dark:text-slate-500"> {leftIcon && (
{isClearable && hasValue && !disabled && ( <InputGroup.Prefix
<button className={`flex items-center justify-center pl-3.5 pr-2 shrink-0 pointer-events-none transition-colors ${
type="button" hasError ? "text-rose-500" : "text-slate-400 dark:text-slate-500"
tabIndex={-1} } ${currentSize.icon}`}>
onClick={onClear} {leftIcon}
aria-label="Limpiar campo" </InputGroup.Prefix>
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors"
>
<FiX className="text-sm" />
</button>
)} )}
{isPasswordType && !disabled && ( {/* Core Input Element */}
<button <HeroInput
type="button" ref={ref}
tabIndex={-1} id={inputId}
onClick={() => setShowPassword((prev) => !prev)} name={name}
aria-label={showPassword ? 'Ocultar contraseña' : 'Ver contraseña'} type={computedType}
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors" value={value}
> disabled={disabled}
{showPassword ? ( required={required}
<FiEyeOff className={currentSize.icon} /> className={`
) : ( flex-1 w-full bg-transparent border-none outline-none focus:ring-0 p-0 m-0 shadow-none text-slate-900 placeholder:text-slate-400
<FiEye className={currentSize.icon} /> dark:text-slate-100 dark:placeholder:text-slate-500
)} ${hasError ? "text-rose-900 dark:text-rose-200" : ""}
</button> ${!leftIcon ? "pl-3.5" : ""}
)} ${!rightIcon && !isClearable && !isPasswordType ? "pr-3.5" : ""}
${className}
`}
{...props}
/>
{!isPasswordType && rightIcon && ( {/* Right Action Icons */}
<div className={`pointer-events-none flex items-center justify-center ${currentSize.icon}`}> <InputGroup.Suffix className="flex items-center gap-1.5 pr-3 pl-1 shrink-0 text-slate-400 dark:text-slate-500">
{rightIcon}
</div> {/* Clear */}
)} {isClearable && hasValue && !disabled && (
</div> <button
type="button"
tabIndex={-1}
onClick={onClear}
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors">
<FiX className="text-sm" />
</button>
)}
{/* Password Toggle */}
{isPasswordType && !disabled && (
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((prev) => !prev)}
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors">
{showPassword ? <FiEyeOff className={currentSize.icon} /> : <FiEye className={currentSize.icon} />}
</button>
)}
{/* Custom Right Icon */}
{!isPasswordType && rightIcon && (
<div className={`pointer-events-none flex items-center justify-center ${currentSize.icon}`}>
{rightIcon}
</div>
)}
</InputGroup.Suffix>
</InputGroup>
</div> </div>
{/* Error or Helper Message */} {/* Error or Helper Message */}
{hasError ? ( {hasError ? (
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn"> <FieldError className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
<FiAlertCircle className="shrink-0 text-xs" /> <FiAlertCircle className="shrink-0 text-xs" />
<span>{errorMessage}</span> <span>{errorMessage}</span>
</p> </FieldError>
) : helperText ? ( ) : helperText ? (
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p> <Description className="text-xs text-slate-500 dark:text-slate-400">{helperText}</Description>
) : null} ) : null}
</div> </TextField>
); );
} },
); );
Input.displayName = 'Input'; Input.displayName = "Input";
+68 -93
View File
@@ -1,13 +1,15 @@
"use client"; "use client";
import React, { useEffect } from 'react';
import { FiX } from 'react-icons/fi'; import React from "react";
import { Modal as HeroModal } from "@heroui/react";
import { FiX } from "react-icons/fi";
export interface ModalProps { export interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title?: React.ReactNode; title?: React.ReactNode;
description?: React.ReactNode; description?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'; size?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
children: React.ReactNode; children: React.ReactNode;
footer?: React.ReactNode; footer?: React.ReactNode;
closeOnOverlayClick?: boolean; closeOnOverlayClick?: boolean;
@@ -16,12 +18,12 @@ export interface ModalProps {
} }
const sizeStyles = { const sizeStyles = {
sm: 'max-w-md', sm: "max-w-md",
md: 'max-w-lg', md: "max-w-lg",
lg: 'max-w-2xl', lg: "max-w-2xl",
xl: 'max-w-4xl', xl: "max-w-4xl",
'2xl': 'max-w-6xl', "2xl": "max-w-6xl",
full: 'max-w-[95vw] h-[90vh]', full: "max-w-[95vw] h-[90vh]",
}; };
export const Modal: React.FC<ModalProps> = ({ export const Modal: React.FC<ModalProps> = ({
@@ -29,101 +31,74 @@ export const Modal: React.FC<ModalProps> = ({
onClose, onClose,
title, title,
description, description,
size = 'md', size = "md",
children, children,
footer, footer,
closeOnOverlayClick = true, closeOnOverlayClick = true,
showCloseButton = true, showCloseButton = true,
className = '', className = "",
}) => { }) => {
// ESC key handler and body scroll lock
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return ( return (
<div <HeroModal>
role="dialog" <HeroModal.Backdrop
aria-modal="true" isOpen={isOpen}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto" isDismissable={closeOnOverlayClick}
> onOpenChange={(open) => {
{/* Backdrop */} if (!open) {
<div onClose();
onClick={() => { }
if (closeOnOverlayClick) onClose();
}} }}
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity animate-fadeIn" variant="blur"
/> className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity animate-fadeIn">
<HeroModal.Container placement="center" scroll="inside" size="md" className="p-4 sm:p-6">
<HeroModal.Dialog
aria-modal="true"
className={`
relative w-full rounded-3xl overflow-hidden shadow-2xl z-10 p-0
bg-white text-slate-900 border border-slate-200
dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-800
transition-all transform animate-scaleUp
${sizeStyles[size]}
${className}
`}>
{/* Modal Dialog Card */} {/* Header */}
<div {(title || description || showCloseButton) && (
className={` <HeroModal.Header className="flex items-start justify-between p-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
relative w-full rounded-3xl overflow-hidden shadow-2xl z-10 <div className="space-y-1 pr-6">
bg-white text-slate-900 border border-slate-200 {title && (
dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-800 <HeroModal.Heading className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
transition-all transform animate-scaleUp {title}
${sizeStyles[size]} </HeroModal.Heading>
${className} )}
`} {description && (
> <p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">{description}</p>
{/* Header */} )}
{(title || description || showCloseButton) && ( </div>
<div className="flex items-start justify-between p-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div className="space-y-1 pr-6">
{title && (
<h2 className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
{title}
</h2>
)}
{description && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
)}
</div>
{showCloseButton && ( {showCloseButton && (
<button <HeroModal.CloseTrigger
type="button" className="p-2 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800 transition-colors"
onClick={onClose} onPress={onClose}
aria-label="Cerrar modal" aria-label="Cerrar modal">
className="p-2 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800 transition-colors" <FiX className="text-lg" />
> </HeroModal.CloseTrigger>
<FiX className="text-lg" /> )}
</button> </HeroModal.Header>
)} )}
</div>
)}
{/* Body Content */} {/* Body Content */}
<div className="p-6 overflow-y-auto max-h-[calc(85vh-160px)]"> <HeroModal.Body className="p-6 overflow-y-auto max-h-[calc(85vh-160px)]">{children}</HeroModal.Body>
{children}
</div>
{/* Footer */} {/* Footer */}
{footer && ( {footer && (
<div className="p-6 pt-4 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-950/40 flex items-center justify-end gap-3"> <HeroModal.Footer className="p-6 pt-4 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-950/40 flex items-center justify-end gap-3">
{footer} {footer}
</div> </HeroModal.Footer>
)} )}
</div> </HeroModal.Dialog>
</div> </HeroModal.Container>
</HeroModal.Backdrop>
</HeroModal>
); );
}; };
+140 -105
View File
@@ -1,6 +1,9 @@
"use client"; "use client";
import React from 'react';
import { FiChevronDown, FiAlertCircle } from 'react-icons/fi'; import React from "react";
import { Select as HeroSelect, Label, Description, FieldError, ListBox, ListBoxItem } from "@heroui/react";
import type { Key } from "@heroui/react";
import { FiChevronDown, FiAlertCircle } from "react-icons/fi";
export interface SelectOption { export interface SelectOption {
value: string | number; value: string | number;
@@ -8,158 +11,190 @@ export interface SelectOption {
disabled?: boolean; disabled?: boolean;
} }
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> { export interface SelectProps {
label?: string; label?: string;
helperText?: string; helperText?: string;
errorMessage?: string; errorMessage?: string;
leftIcon?: React.ReactNode; leftIcon?: React.ReactNode;
options?: SelectOption[]; options?: SelectOption[];
sizeVariant?: 'sm' | 'md' | 'lg'; sizeVariant?: "sm" | "md" | "lg";
fullWidth?: boolean; fullWidth?: boolean;
id?: string;
name?: string;
disabled?: boolean;
required?: boolean;
value?: string | number;
defaultValue?: string | number;
onChange?: (value: string | null) => void;
className?: string;
children?: React.ReactNode;
} }
const sizeStyles = { const sizeStyles = {
sm: { sm: {
select: 'py-2 text-xs rounded-lg', select: "py-2 text-xs rounded-lg",
icon: 'text-sm', icon: "text-sm",
leftPad: 'pl-8', leftPad: "pl-8",
rightPad: 'pr-8', rightPad: "pr-8",
}, },
md: { md: {
select: 'py-2.5 text-sm rounded-xl', select: "py-2.5 text-sm rounded-xl",
icon: 'text-base', icon: "text-base",
leftPad: 'pl-10', leftPad: "pl-10",
rightPad: 'pr-10', rightPad: "pr-10",
}, },
lg: { lg: {
select: 'py-3.5 text-base rounded-2xl', select: "py-3.5 text-base rounded-2xl",
icon: 'text-lg', icon: "text-lg",
leftPad: 'pl-12', leftPad: "pl-12",
rightPad: 'pr-12', rightPad: "pr-12",
}, },
}; };
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>( export const Select = React.forwardRef<HTMLDivElement, SelectProps>(
( (
{ {
label, label,
helperText, helperText,
errorMessage, errorMessage,
leftIcon, leftIcon,
options, options = [],
sizeVariant = 'md', sizeVariant = "md",
fullWidth = true, fullWidth = true,
id, id,
name, name,
disabled, disabled,
required, required,
className = '', className = "",
value,
defaultValue,
onChange,
children, children,
...props
}, },
ref ref,
) => { ) => {
const selectId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined); const selectId = id || name || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined);
const hasError = Boolean(errorMessage); const hasError = Boolean(errorMessage);
const currentSize = sizeStyles[sizeVariant]; const currentSize = sizeStyles[sizeVariant];
const selectedKey = value !== undefined && value !== null && value !== "" ? String(value) : undefined;
const defaultSelectedKey =
defaultValue !== undefined && defaultValue !== null && defaultValue !== "" ? String(defaultValue) : undefined;
const handleSelectionChange = (key: Key | null) => {
if (!onChange) return;
onChange(key !== null ? String(key) : null);
};
return ( return (
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}> <div className={`${fullWidth ? "w-full" : "inline-block"} space-y-1.5`}>
{/* Label */} <HeroSelect
{label && ( ref={ref}
<div className="flex items-center justify-between"> id={selectId}
<label name={name}
htmlFor={selectId} selectedKey={selectedKey}
defaultSelectedKey={defaultSelectedKey}
onSelectionChange={handleSelectionChange}
isDisabled={disabled}
isRequired={required}
isInvalid={hasError}
className="w-full">
{label && (
<Label
className={`block text-xs font-semibold select-none transition-colors ${ className={`block text-xs font-semibold select-none transition-colors ${
hasError hasError ? "text-rose-600 dark:text-rose-400" : "text-slate-700 dark:text-slate-300"
? 'text-rose-600 dark:text-rose-400' }`}>
: 'text-slate-700 dark:text-slate-300'
}`}
>
{label} {label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>} {required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label> </Label>
</div>
)}
{/* Select Wrapper */}
<div className="relative flex items-center">
{/* Left Icon */}
{leftIcon && (
<div
className={`absolute left-3.5 pointer-events-none flex items-center justify-center transition-colors ${
hasError
? 'text-rose-500'
: 'text-slate-400 dark:text-slate-500'
} ${currentSize.icon}`}
>
{leftIcon}
</div>
)} )}
{/* Core Select Element */} <HeroSelect.Trigger
<select
ref={ref}
id={selectId}
name={name}
disabled={disabled}
required={required}
className={` className={`
w-full px-3.5 font-normal transition-all duration-200 outline-none appearance-none cursor-pointer relative flex w-full items-center
font-normal transition-all duration-200
outline-none appearance-none cursor-pointer
bg-white text-slate-900 bg-white text-slate-900
border border-slate-300 border border-slate-300
hover:border-slate-400 hover:border-slate-400
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15 focus:border-cyan-500
dark:bg-slate-900/90 dark:text-slate-100 focus:ring-4
dark:border-slate-800 dark:hover:border-slate-700 focus:ring-cyan-500/15
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25 dark:bg-slate-900/90
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed dark:text-slate-100
${leftIcon ? currentSize.leftPad : ''} dark:border-slate-800
${currentSize.rightPad} dark:hover:border-slate-700
${ dark:focus:border-cyan-500
hasError dark:focus:ring-cyan-500/25
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20' disabled:opacity-60
: '' disabled:bg-slate-100
} disabled:dark:bg-slate-950
disabled:cursor-not-allowed
${currentSize.select} ${currentSize.select}
${leftIcon ? currentSize.leftPad : ""}
${currentSize.rightPad}
${hasError ? "border-rose-500! ring-rose-500/20! text-rose-900 dark:text-rose-200" : ""}
${className} ${className}
`} `}>
{...props} {leftIcon && (
> <span
{options className={`
? options.map((opt) => ( absolute left-3.5
<option pointer-events-none
key={opt.value} flex items-center justify-center
value={opt.value} transition-colors
disabled={opt.disabled} ${hasError ? "text-rose-500" : "text-slate-400 dark:text-slate-500"}
className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100" ${currentSize.icon}
> `}>
{opt.label} {leftIcon}
</option> </span>
)) )}
: children}
</select>
{/* Right Chevron Icon */} <HeroSelect.Value className="w-full text-left" />
<div
className={`absolute right-3.5 pointer-events-none flex items-center justify-center text-slate-400 dark:text-slate-500 ${currentSize.icon}`}
>
<FiChevronDown />
</div>
</div>
{/* Error or Helper Message */} <HeroSelect.Indicator
{hasError ? ( className={`
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn"> absolute right-3.5
<FiAlertCircle className="shrink-0 text-xs" /> pointer-events-none
<span>{errorMessage}</span> flex items-center justify-center
</p> text-slate-400 dark:text-slate-500
) : helperText ? ( ${currentSize.icon}
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p> `}>
) : null} <FiChevronDown />
</HeroSelect.Indicator>
</HeroSelect.Trigger>
<HeroSelect.Popover className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-xl overflow-hidden z-50 min-w-(--trigger-width)">
<ListBox className="p-1 outline-none">
{options.length > 0
? options.map((option) => (
<ListBoxItem
key={String(option.value)}
id={String(option.value)}
textValue={option.label}
isDisabled={option.disabled}
className="px-3 py-2 text-sm rounded-lg cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 data-[selected=true]:bg-cyan-500/10 data-[selected=true]:text-cyan-600 dark:data-[selected=true]:text-cyan-400 outline-none transition-colors">
{option.label}
</ListBoxItem>
))
: (children as any)}
</ListBox>
</HeroSelect.Popover>
{hasError ? (
<FieldError className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400">
<FiAlertCircle className="shrink-0 text-xs" />
<span>{errorMessage}</span>
</FieldError>
) : helperText ? (
<Description className="text-xs text-slate-500 dark:text-slate-400">{helperText}</Description>
) : null}
</HeroSelect>
</div> </div>
); );
} },
); );
Select.displayName = 'Select'; Select.displayName = "Select";
+23 -27
View File
@@ -1,45 +1,41 @@
"use client"; "use client";
import React from 'react';
import React from "react";
import { Spinner as HeroSpinner } from "@heroui/react";
export interface SpinnerProps { export interface SpinnerProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: "xs" | "sm" | "md" | "lg" | "xl";
color?: 'cyan' | 'blue' | 'white' | 'slate' | 'current'; color?: "cyan" | "blue" | "white" | "slate" | "current";
className?: string; className?: string;
label?: string; label?: string;
} }
const sizeClasses = { const sizeClasses = {
xs: 'w-3.5 h-3.5 border-2', xs: "w-3.5 h-3.5",
sm: 'w-4 h-4 border-2', sm: "w-4 h-4",
md: 'w-6 h-6 border-2', md: "w-6 h-6",
lg: 'w-8 h-8 border-3', lg: "w-8 h-8",
xl: 'w-12 h-12 border-4', xl: "w-12 h-12",
}; };
const colorClasses = { const colorClasses = {
cyan: 'border-cyan-500/20 border-t-cyan-500 dark:border-cyan-400/20 dark:border-t-cyan-400', cyan: "text-cyan-500 dark:text-cyan-400",
blue: 'border-blue-500/20 border-t-blue-500 dark:border-blue-400/20 dark:border-t-blue-400', blue: "text-blue-500 dark:text-blue-400",
white: 'border-white/30 border-t-white', white: "text-white",
slate: 'border-slate-300 border-t-slate-700 dark:border-slate-700 dark:border-t-slate-200', slate: "text-slate-700 dark:text-slate-200",
current: 'border-current/20 border-t-current', current: "text-current",
}; };
export const Spinner: React.FC<SpinnerProps> = ({ export const Spinner: React.FC<SpinnerProps> = ({
size = 'md', size = "md",
color = 'cyan', color = "cyan",
className = '', className = "",
label = 'Cargando...', label = "Cargando...",
}) => { }) => {
return ( return (
<div <HeroSpinner
role="status"
aria-label={label} aria-label={label}
className={`inline-flex items-center justify-center ${className}`} className={`inline-block animate-spin ${sizeClasses[size]} ${colorClasses[color]} ${className}`}
> />
<div
className={`rounded-full animate-spin ${sizeClasses[size]} ${colorClasses[color]}`}
/>
<span className="sr-only">{label}</span>
</div>
); );
}; };
+102 -81
View File
@@ -1,5 +1,11 @@
"use client"; "use client";
import React from 'react';
import React from "react";
import {
Switch as HeroSwitch,
Label,
Description,
} from "@heroui/react";
export interface SwitchProps { export interface SwitchProps {
label?: React.ReactNode; label?: React.ReactNode;
@@ -7,7 +13,7 @@ export interface SwitchProps {
checked?: boolean; checked?: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
disabled?: boolean; disabled?: boolean;
size?: 'sm' | 'md' | 'lg'; size?: "sm" | "md" | "lg";
checkedIcon?: React.ReactNode; checkedIcon?: React.ReactNode;
uncheckedIcon?: React.ReactNode; uncheckedIcon?: React.ReactNode;
id?: string; id?: string;
@@ -16,21 +22,21 @@ export interface SwitchProps {
} }
const trackSizes = { const trackSizes = {
sm: 'w-8 h-4.5', sm: "w-8 h-[18px]",
md: 'w-11 h-6', md: "w-11 h-6",
lg: 'w-14 h-7.5', lg: "w-14 h-[30px]",
}; };
const thumbSizes = { const thumbSizes = {
sm: 'w-3.5 h-3.5', sm: "size-3.5",
md: 'w-5 h-5', md: "size-5",
lg: 'w-6.5 h-6.5', lg: "size-[26px]",
}; };
const translateSizes = { const translateSizes = {
sm: 'translate-x-3.5', sm: "translate-x-3.5",
md: 'translate-x-5', md: "translate-x-5",
lg: 'translate-x-6.5', lg: "translate-x-[26px]",
}; };
export const Switch: React.FC<SwitchProps> = ({ export const Switch: React.FC<SwitchProps> = ({
@@ -39,85 +45,100 @@ export const Switch: React.FC<SwitchProps> = ({
checked = false, checked = false,
onChange, onChange,
disabled = false, disabled = false,
size = 'md', size = "md",
checkedIcon, checkedIcon,
uncheckedIcon, uncheckedIcon,
id, id,
name, name,
className = '', className = "",
}) => { }) => {
const switchId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined); const switchId =
id ||
const handleClick = () => { name ||
if (!disabled && onChange) { (typeof label === "string"
onChange(!checked); ? label.toLowerCase().replace(/\s+/g, "-")
} : undefined);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
handleClick();
}
};
return ( return (
<div className={`inline-flex items-start gap-3 select-none ${className}`}> <HeroSwitch
{/* Switch Button */} id={switchId}
<button name={name}
id={switchId} isSelected={checked}
type="button" isDisabled={disabled}
role="switch" size={size}
aria-checked={checked} className={className}
disabled={disabled} onChange={(e: any) => {
onClick={handleClick} if (!onChange || disabled) return;
onKeyDown={handleKeyDown} const val = typeof e === "boolean" ? e : Boolean(e?.target?.checked);
className={` onChange(val);
relative shrink-0 inline-flex items-center rounded-full p-0.5 cursor-pointer transition-colors duration-200 ease-in-out }}
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 >
focus-visible:ring-cyan-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 <HeroSwitch.Content>
disabled:opacity-50 disabled:cursor-not-allowed <HeroSwitch.Control
${trackSizes[size]}
${
checked
? 'bg-cyan-500 dark:bg-cyan-500'
: 'bg-slate-300 dark:bg-slate-700 hover:bg-slate-400 dark:hover:bg-slate-600'
}
`}
>
{/* Thumb */}
<span
className={` className={`
pointer-events-none flex items-center justify-center rounded-full bg-white shadow-md transform transition-transform duration-200 ease-in-out text-slate-800 ${trackSizes[size]}
${thumbSizes[size]} relative shrink-0 inline-flex items-center rounded-full p-0.5
${checked ? translateSizes[size] : 'translate-x-0'} transition-colors duration-200 ease-in-out cursor-pointer
border-0 m-0 box-border outline-none
${
checked
? "bg-cyan-500"
: "bg-slate-300 hover:bg-slate-400 dark:bg-slate-700 dark:hover:bg-slate-600"
}
${disabled ? "cursor-not-allowed opacity-50" : ""}
`} `}
> >
{checked ? checkedIcon : uncheckedIcon} <HeroSwitch.Thumb
</span> className={`
</button> ${thumbSizes[size]}
${checked ? translateSizes[size] : "translate-x-0"}
border-0 m-0 pointer-events-none shrink-0 flex items-center justify-center
rounded-full bg-white text-slate-800 shadow-md
transition-transform duration-200 ease-in-out
`}
>
{(checkedIcon || uncheckedIcon) && (
<HeroSwitch.Icon>
{checked ? checkedIcon : uncheckedIcon}
</HeroSwitch.Icon>
)}
</HeroSwitch.Thumb>
</HeroSwitch.Control>
{/* Label and description */} {/* TEXTOS */}
{(label || description) && ( {(label || description) && (
<div className="flex flex-col text-left cursor-pointer" onClick={handleClick}> <div className="flex flex-col text-left">
{label && ( {label && (
<span <Label
className={`text-xs sm:text-sm font-medium transition-colors ${ className={`
disabled text-xs sm:text-sm font-medium transition-colors cursor-pointer
? 'text-slate-400 dark:text-slate-600' ${
: 'text-slate-800 dark:text-slate-200 hover:text-slate-950 dark:hover:text-white' disabled
}`} ? "text-slate-400 dark:text-slate-600 cursor-not-allowed"
> : "text-slate-800 hover:text-slate-950 dark:text-slate-200 dark:hover:text-white"
{label} }
</span> `}
)} >
{description && ( {label}
<span className="text-xs text-slate-500 dark:text-slate-400"> </Label>
{description} )}
</span>
)} {description && (
</div> <Description
)} className={`
</div> text-xs text-slate-500 dark:text-slate-400 cursor-pointer
${disabled ? "cursor-not-allowed" : ""}
`}
>
{description}
</Description>
)}
</div>
)}
</HeroSwitch.Content>
</HeroSwitch>
); );
}; };