Compare commits

...
10 Commits
11 changed files with 1163 additions and 856 deletions

No files matched your search

+4 -4
View File
@@ -279,8 +279,8 @@ export default function ComponentsContent() {
<Row>
<Checkbox
label="Recordar sesión"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
isSelected={checked}
onChange={(isSelected) => setChecked(isSelected)}
/>
<Checkbox label="Deshabilitado" disabled />
<Checkbox
@@ -302,8 +302,8 @@ export default function ComponentsContent() {
<CodeBlock
code={`<Checkbox
label="Recordar sesión"
checked={checked}
onChange={e => setChecked(e.target.checked)}
isSelected={checked}
onChange={(isSelected) => setChecked(isSelected)}
/>
<Switch
+131 -97
View File
@@ -1,163 +1,197 @@
"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({
// Desactivamos los estilos de sombra/borde base de HeroUI para tener control total
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',
}
});
import React from "react";
import { Card as HeroUICard } from "@heroui/react";
// 1. Omitimos las props conflictivas y declaramos tu API
export interface CardProps extends Omit<HeroUICardProps, 'className' | 'children' | 'variant'> {
variant?: 'default' | 'elevated' | 'glass' | 'outline' | 'interactive';
padding?: 'none' | 'sm' | 'md' | 'lg';
export type CardVariant =
| "default"
| "elevated"
| "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;
children?: React.ReactNode;
}
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'default',
padding = 'none',
className = '',
const variantStyles: Record<CardVariant, string> = {
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",
};
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
},
ref
) => {
}: CardProps) => {
return (
<HeroUICard
ref={ref}
className={cardStyles({
customVariant: variant,
customPadding: padding,
className
})}
// El radius interno de HeroUI se sobrescribe usando tu 'rounded-2xl'
{...props}
className={[
"rounded-2xl overflow-hidden transition-colors",
variantStyles[variant],
paddingStyles[padding],
className,
]
.filter(Boolean)
.join(" ")}
>
{children}
</HeroUICard>
);
}
);
Card.displayName = 'Card';
};
// 2. Subcomponentes: Mapeados a HeroUI
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
export interface CardHeaderProps
extends React.ComponentProps<typeof HeroUICard.Header> {
bordered?: boolean;
}
export const CardHeader: React.FC<CardHeaderProps> = ({
export const CardHeader = ({
bordered = false,
className = '',
className = "",
children,
...props
}) => {
}: CardHeaderProps) => {
return (
<HeroUICardHeader
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}
`}
<HeroUICard.Header
{...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}
</HeroUICardHeader>
</HeroUICard.Header>
);
};
// 3. CardTitle y CardDescription: Se mantienen como HTML estándar
export const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className = '',
export const CardTitle = ({
className = "",
children,
...props
}) => {
}: React.ComponentProps<typeof HeroUICard.Title>) => {
return (
<h3
className={`text-lg sm:text-xl font-bold tracking-tight text-slate-900 dark:text-white transition-colors ${className}`}
<HeroUICard.Title
{...props}
className={[
"text-lg sm:text-xl font-bold tracking-tight",
"text-slate-900 dark:text-white",
"transition-colors",
className,
]
.filter(Boolean)
.join(" ")}
>
{children}
</h3>
</HeroUICard.Title>
);
};
export const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className = '',
export const CardDescription = ({
className = "",
children,
...props
}) => {
}: React.ComponentProps<typeof HeroUICard.Description>) => {
return (
<p
className={`text-xs sm:text-sm text-slate-500 dark:text-slate-400 leading-relaxed ${className}`}
<HeroUICard.Description
{...props}
className={[
"text-xs sm:text-sm",
"text-slate-500 dark:text-slate-400",
"leading-relaxed",
className,
]
.filter(Boolean)
.join(" ")}
>
{children}
</p>
</HeroUICard.Description>
);
};
// 4. CardContent: Mapeado a CardBody de HeroUI
export const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className = '',
export const CardContent = ({
className = "",
children,
...props
}) => {
}: React.ComponentProps<typeof HeroUICard.Content>) => {
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}
</div>
</HeroUICard.Content>
);
};
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
export interface CardFooterProps
extends React.ComponentProps<typeof HeroUICard.Footer> {
bordered?: boolean;
}
export const CardFooter: React.FC<CardFooterProps> = ({
export const CardFooter = ({
bordered = false,
className = '',
className = "",
children,
...props
}) => {
}: CardFooterProps) => {
return (
<HeroUICardFooter
className={`
p-5 sm:p-6 flex items-center justify-between
${bordered ? 'border-t border-slate-100 dark:border-slate-800/80' : ''}
${className}
`}
<HeroUICard.Footer
{...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}
</HeroUICardFooter>
</HeroUICard.Footer>
);
};
+112 -76
View File
@@ -1,112 +1,148 @@
"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;
description?: string;
errorMessage?: string;
checked?: boolean;
disabled?: boolean;
indeterminate?: boolean;
className?: string;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
(
{
export const Checkbox = ({
label,
description,
errorMessage,
indeterminate = false,
checked,
disabled,
className = '',
isSelected,
disabled = false,
className = "",
id,
name,
onChange,
...props
},
forwardedRef
) => {
const internalRef = useRef<HTMLInputElement>(null);
const resolvedRef = (forwardedRef as React.RefObject<HTMLInputElement>) || internalRef;
const checkboxId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
useEffect(() => {
if (resolvedRef.current) {
resolvedRef.current.indeterminate = indeterminate;
}
}, [indeterminate, resolvedRef]);
const isChecked = Boolean(checked);
}: CheckboxProps) => {
return (
<div className={`flex flex-col space-y-1 ${className}`}>
<label
htmlFor={checkboxId}
className={`inline-flex items-start gap-2.5 select-none cursor-pointer group ${
disabled ? 'opacity-50 !cursor-not-allowed' : ''
}`}
>
{/* Custom Styled Box */}
<div className="relative flex items-center justify-center mt-0.5 shrink-0">
<input
ref={resolvedRef}
id={checkboxId}
name={name}
type="checkbox"
checked={checked}
disabled={disabled}
className="peer sr-only"
<HeroUICheckbox
{...props}
/>
<div
className={`
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
peer-focus-visible:ring-cyan-500 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-950
${
isChecked || indeterminate
? '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'
}
${
errorMessage
? '!border-rose-500 !bg-rose-50 dark:!bg-rose-950/30'
: ''
}
`}
id={id}
name={name}
isSelected={checked}
isDisabled={disabled}
isIndeterminate={indeterminate}
isInvalid={Boolean(errorMessage)}
onChange={onChange}
className={className}
>
{indeterminate ? (
<FiMinus className="text-xs stroke-3" />
) : isChecked ? (
<FiCheck className="text-xs stroke-3" />
) : null}
</div>
</div>
<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",
// Estado normal
"bg-white dark:bg-slate-900",
"border-slate-300 dark:border-slate-700",
// Hover
"group-hover:border-slate-400",
"dark:group-hover:border-slate-500",
// Focus
"data-[focus-visible=true]:ring-2",
"data-[focus-visible=true]:ring-cyan-500",
"data-[focus-visible=true]:ring-offset-2",
"data-[focus-visible=true]:ring-offset-white",
"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(" ")}
>
<HeroUICheckbox.Indicator>
{({ isSelected, isIndeterminate }) => {
if (isIndeterminate) {
return <FiMinus className="text-xs stroke-3" />;
}
if (isSelected) {
return <FiCheck className="text-xs stroke-3" />;
}
return null;
}}
</HeroUICheckbox.Indicator>
</HeroUICheckbox.Control>
{/* 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">
<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
className="
text-xs
text-slate-500 dark:text-slate-400
"
>
{description}
</span>
</Description>
)}
</div>
)}
</label>
</HeroUICheckbox.Content>
{errorMessage && (
<p className="text-xs text-rose-600 dark:text-rose-400 pl-6.5">{errorMessage}</p>
<FieldError className="pl-6.5 text-xs">
{errorMessage}
</FieldError>
)}
</div>
);
}
</HeroUICheckbox>
);
};
Checkbox.displayName = 'Checkbox';
Checkbox.displayName = "Checkbox";
+317 -251
View File
@@ -1,289 +1,355 @@
"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;
value?: string; // "YYYY-MM-DD"
onChange: (value: string | undefined) => void;
value?: string; //Fecha en formato YYYY-MM-DD o undefined cuando se limpia
onChange: (value: string | undefined) => void; //Devuelve la fecha en formato YYYY-MM-DD o undefined cuando se limpia.
placeholder?: string;
required?: boolean;
maxDate?: Date;
maxDate?: Date; //Fecha máxima permitida.
className?: string;
errorMessage?: string;
}
const MONTHS = [
"Enero","Febrero","Marzo","Abril","Mayo","Junio",
"Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre",
];
const DAYS = ["Do","Lu","Ma","Mi","Ju","Vi","Sa"];
const DROPDOWN_H = 320; // px estimado del dropdown
function parseDate(value?: string): Date | undefined {
if (!value) return undefined;
const d = new Date(value + "T12:00:00");
return isNaN(d.getTime()) ? undefined : d;
function toCalendarDate(dateStr?: string): DateValue | undefined {
if (!dateStr) {
return undefined;
}
function formatDisplay(value?: string): string {
const d = parseDate(value);
if (!d) return "";
return d.toLocaleDateString("es-MX", { day: "numeric", month: "long", year: "numeric" });
try {
return parseInternationalDate(dateStr);
} catch {
return undefined;
}
}
export function DatePicker({
function toDateString(date: DateValue): string {
const yyyy = date.year;
const mm = String(date.month).padStart(2, "0");
const dd = String(date.day).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
export const DatePicker = React.forwardRef<
HTMLDivElement,
DatePickerProps
>(
(
{
label,
value,
onChange,
placeholder = "Selecciona una fecha",
required,
maxDate = new Date(),
}: DatePickerProps) {
const selected = parseDate(value);
const [open, setOpen] = useState(false);
const [view, setView] = useState<"days" | "months" | "years">("days");
const [cursor, setCursor] = useState(() => {
const d = selected ?? new Date();
return { month: d.getMonth(), year: d.getFullYear() };
});
const [pos, setPos] = useState({ top: 0, left: 0, width: 0, openUp: false });
placeholder,
required = false,
maxDate,
className = "",
errorMessage,
},
ref
) => {
const dateValue = useMemo(
() => toCalendarDate(value),
[value]
);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const maxDateValue = useMemo<DateValue | undefined>(() => {
if (!maxDate) {
return undefined;
}
const calcPos = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - r.bottom;
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,
});
}, []);
try {
const yyyy = maxDate.getFullYear();
const mm = String(maxDate.getMonth() + 1).padStart(2, "0");
const dd = String(maxDate.getDate()).padStart(2, "0");
const openPicker = () => {
if (selected) setCursor({ month: selected.getMonth(), year: selected.getFullYear() });
setView("days");
calcPos();
setOpen(true);
return parseInternationalDate(`${yyyy}-${mm}-${dd}`);
} catch {
return undefined;
}
}, [maxDate]);
const handleChange = (newDate: DateValue | null) => {
if (newDate) {
onChange(toDateString(newDate));
} else {
onChange(undefined);
}
};
useEffect(() => {
if (!open) return;
const onScroll = () => calcPos();
const onResize = () => calcPos();
const onMouse = (e: MouseEvent) => {
if (
triggerRef.current?.contains(e.target as Node) ||
dropdownRef.current?.contains(e.target as Node)
) return;
setOpen(false);
const handleClear = () => {
onChange(undefined);
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
document.addEventListener("mousedown", onMouse);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
document.removeEventListener("mousedown", onMouse);
};
}, [open, calcPos]);
const selectDay = (day: number) => {
const d = new Date(cursor.year, cursor.month, day);
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
onChange(iso);
setOpen(false);
};
const daysInMonth = new Date(cursor.year, cursor.month + 1, 0).getDate();
const firstDow = new Date(cursor.year, cursor.month, 1).getDay();
const today = new Date();
const isDisabled = (day: number) => new Date(cursor.year, cursor.month, day) > maxDate;
const isSelected = (day: number) =>
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">
<HeroUIDatePicker.Root
ref={ref}
value={dateValue ?? null}
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 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>
{required && (
<span className="ml-1 font-bold text-rose-500">
*
</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"}
`}
{/* Campo de fecha */}
<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(" ")}
>
{value ? formatDisplay(value) : placeholder}
</button>
{/* 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={() => 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"
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>
)}
</div>
{typeof window !== "undefined" && createPortal(dropdown, document.body)}
{/* 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";
+88 -52
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { FiCheck, FiChevronDown, FiSearch, FiX } from "react-icons/fi";
import { Popover as HeroUIPopover } from "@heroui/react";
import { useTheme } from "../../context/ThemeContext";
export interface DropdownOption {
@@ -27,7 +28,6 @@ export const Dropdown: React.FC<DropdownProps> = ({
const { isDark } = useTheme();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filtered = options.filter((o) =>
@@ -35,24 +35,18 @@ export const Dropdown: React.FC<DropdownProps> = ({
);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
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>
);
};
+141 -27
View File
@@ -1,6 +1,8 @@
"use client";
import { useRef, useState } from "react";
import { FiUpload, FiX } from "react-icons/fi";
import { Button, CloseButton, Label } from "@heroui/react";
import { useTheme } from "@/context/ThemeContext";
export interface ImageUploadProps {
@@ -11,8 +13,15 @@ export interface ImageUploadProps {
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 inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
@@ -20,34 +29,118 @@ export function ImageUpload({ label, value, onChange, aspectRatio = "square", hi
const handleFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
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);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
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 bg = isDark ? "bg-slate-800/50" : "bg-slate-50";
const active = dragging ? (isDark ? "border-violet-500 bg-violet-500/10" : "border-violet-400 bg-violet-50") : "";
const muted = isDark ? "text-slate-400" : "text-slate-500";
const borderColor = isDark
? "border-slate-700"
: "border-slate-200";
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 (
<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>
<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"}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
role="button"
tabIndex={0}
aria-label={`Seleccionar imagen para ${label}`}
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}
>
{value ? (
@@ -55,34 +148,55 @@ export function ImageUpload({ label, value, onChange, aspectRatio = "square", hi
<img
src={value}
alt={label}
className="w-full h-full object-cover"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange(undefined); }}
className="absolute top-2 right-2 p-1 rounded-full bg-black/60 text-white hover:bg-black/80 transition"
<div
className="absolute right-2 top-2"
onClick={(event) => event.stopPropagation()}
>
<CloseButton
aria-label="Eliminar imagen"
onPress={handleRemove}
className="rounded-full bg-black/60 text-white hover:bg-black/80"
>
<FiX className="text-xs" />
</button>
</CloseButton>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full gap-1.5 px-4 text-center">
<FiUpload className={`text-xl ${muted}`} />
<p className={`text-xs ${muted}`}>
<div className="flex h-full flex-col items-center justify-center gap-1.5 px-4 text-center">
<FiUpload className={`text-xl ${mutedColor}`} />
<p className={`text-xs ${mutedColor}`}>
{dragging ? "Suelta aquí" : "Click o arrastra"}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onPress={openFilePicker}
className="mt-1"
>
Seleccionar
</Button>
</div>
)}
</div>
{hint && <p className={`text-[11px] mt-1 ${muted}`}>{hint}</p>}
{hint && (
<p className={`mt-1 text-[11px] ${mutedColor}`}>
{hint}
</p>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
onChange={handleInputChange}
/>
</div>
);
+79 -85
View File
@@ -1,8 +1,9 @@
"use client";
import React, { useState } from 'react';
import { FiEye, FiEyeOff, FiX, FiAlertCircle } from 'react-icons/fi';
import React, { useState } from "react";
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;
helperText?: string;
errorMessage?: string;
@@ -10,28 +11,28 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
rightIcon?: React.ReactNode;
isClearable?: boolean;
onClear?: () => void;
sizeVariant?: 'sm' | 'md' | 'lg';
sizeVariant?: "sm" | "md" | "lg";
fullWidth?: boolean;
}
const sizeStyles = {
sm: {
input: 'py-2 text-xs rounded-lg',
icon: 'text-sm',
leftPad: 'pl-8',
rightPad: 'pr-8',
input: "py-2 text-xs rounded-lg",
icon: "text-sm",
leftPad: "pl-8",
rightPad: "pr-8",
},
md: {
input: 'py-2.5 text-sm rounded-xl',
icon: 'text-base',
leftPad: 'pl-10',
rightPad: 'pr-10',
input: "py-2.5 text-sm rounded-xl",
icon: "text-base",
leftPad: "pl-10",
rightPad: "pr-10",
},
lg: {
input: 'py-3.5 text-base rounded-2xl',
icon: 'text-lg',
leftPad: 'pl-12',
rightPad: 'pr-12',
input: "py-3.5 text-base rounded-2xl",
icon: "text-lg",
leftPad: "pl-12",
rightPad: "pr-12",
},
};
@@ -45,65 +46,72 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
rightIcon,
isClearable = false,
onClear,
sizeVariant = 'md',
sizeVariant = "md",
fullWidth = true,
type = 'text',
type = "text",
id,
name,
disabled,
required,
className = '',
className = "",
value,
...props
},
ref
ref,
) => {
const [showPassword, setShowPassword] = useState(false);
const inputId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const isPasswordType = type === 'password';
const computedType = isPasswordType ? (showPassword ? 'text' : 'password') : type;
const inputId = id || name || (label ? label.toLowerCase().replace(/\s+/g, "-") : undefined);
const isPasswordType = type === "password";
const computedType = isPasswordType ? (showPassword ? "text" : "password") : type;
const hasError = Boolean(errorMessage);
const hasValue = value !== undefined && value !== '' && value !== null;
const hasValue = value !== undefined && value !== "" && value !== null;
const currentSize = sizeStyles[sizeVariant];
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 && (
<div className="flex items-center justify-between">
<label
<Label
htmlFor={inputId}
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" : "text-slate-700 dark:text-slate-300"
}`}>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
</div>
{required}
</Label>
)}
{/* Input Wrapper */}
<div className="relative flex items-center">
<div className="relative flex items-center w-full">
<InputGroup
className={`
flex items-center w-full overflow-hidden transition-all duration-200 outline-none
bg-white border border-slate-300 hover:border-slate-400
focus-within:border-cyan-500 focus-within:ring-4 focus-within:ring-cyan-500/15
dark:bg-slate-900/90 dark:border-slate-800 dark:hover:border-slate-700
dark:focus-within:border-cyan-500 dark:focus-within:ring-cyan-500/25
${disabled ? "opacity-60 bg-slate-100 dark:bg-slate-950 cursor-not-allowed" : ""}
${hasError ? "border-rose-500! ring-rose-500/20! focus-within:border-rose-500! focus-within:ring-rose-500/20!" : ""}
${currentSize.input} `}>
{/* 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}`}
>
<InputGroup.Prefix
className={`flex items-center justify-center pl-3.5 pr-2 shrink-0 pointer-events-none transition-colors ${
hasError ? "text-rose-500" : "text-slate-400 dark:text-slate-500"
} ${currentSize.icon}`}>
{leftIcon}
</div>
</InputGroup.Prefix>
)}
{/* Core Input Element */}
<input
<HeroInput
ref={ref}
id={inputId}
name={name}
@@ -112,78 +120,64 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
disabled={disabled}
required={required}
className={`
w-full px-3.5 font-normal transition-all duration-200 outline-none
bg-white text-slate-900 placeholder:text-slate-400
border border-slate-300
hover:border-slate-400
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:bg-slate-900/90 dark:text-slate-100 dark:placeholder:text-slate-500
dark:border-slate-800 dark:hover:border-slate-700
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
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}
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
dark:text-slate-100 dark:placeholder:text-slate-500
${hasError ? "text-rose-900 dark:text-rose-200" : ""}
${!leftIcon ? "pl-3.5" : ""}
${!rightIcon && !isClearable && !isPasswordType ? "pr-3.5" : ""}
${className}
`}
{...props}
/>
{/* Right Action Icons (Clear, Password Toggle, Custom RightIcon) */}
<div className="absolute right-3 flex items-center gap-1.5 text-slate-400 dark:text-slate-500">
{/* Right Action Icons */}
<InputGroup.Suffix className="flex items-center gap-1.5 pr-3 pl-1 shrink-0 text-slate-400 dark:text-slate-500">
{/* Clear */}
{isClearable && hasValue && !disabled && (
<button
type="button"
tabIndex={-1}
onClick={onClear}
aria-label="Limpiar campo"
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"
>
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)}
aria-label={showPassword ? 'Ocultar contraseña' : 'Ver contraseña'}
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} />
)}
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>
)}
</div>
</InputGroup.Suffix>
</InputGroup>
</div>
{/* Error or Helper Message */}
{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" />
<span>{errorMessage}</span>
</p>
</FieldError>
) : 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}
</div>
</TextField>
);
}
},
);
Input.displayName = 'Input';
Input.displayName = "Input";
+44 -69
View File
@@ -1,13 +1,15 @@
"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 {
isOpen: boolean;
onClose: () => void;
title?: React.ReactNode;
description?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
children: React.ReactNode;
footer?: React.ReactNode;
closeOnOverlayClick?: boolean;
@@ -16,12 +18,12 @@ export interface ModalProps {
}
const sizeStyles = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
'2xl': 'max-w-6xl',
full: 'max-w-[95vw] h-[90vh]',
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
"2xl": "max-w-6xl",
full: "max-w-[95vw] h-[90vh]",
};
export const Modal: React.FC<ModalProps> = ({
@@ -29,101 +31,74 @@ export const Modal: React.FC<ModalProps> = ({
onClose,
title,
description,
size = 'md',
size = "md",
children,
footer,
closeOnOverlayClick = true,
showCloseButton = true,
className = '',
className = "",
}) => {
// ESC key handler and body scroll lock
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
return (
<HeroModal>
<HeroModal.Backdrop
isOpen={isOpen}
isDismissable={closeOnOverlayClick}
onOpenChange={(open) => {
if (!open) {
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 (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto"
>
{/* Backdrop */}
<div
onClick={() => {
if (closeOnOverlayClick) onClose();
}}
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity animate-fadeIn"
/>
{/* Modal Dialog Card */}
<div
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
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}
`}
>
`}>
{/* Header */}
{(title || description || showCloseButton) && (
<div className="flex items-start justify-between p-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<HeroModal.Header 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">
<HeroModal.Heading className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
{title}
</h2>
</HeroModal.Heading>
)}
{description && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">{description}</p>
)}
</div>
{showCloseButton && (
<button
type="button"
onClick={onClose}
aria-label="Cerrar modal"
<HeroModal.CloseTrigger
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"
>
onPress={onClose}
aria-label="Cerrar modal">
<FiX className="text-lg" />
</button>
</HeroModal.CloseTrigger>
)}
</div>
</HeroModal.Header>
)}
{/* Body Content */}
<div className="p-6 overflow-y-auto max-h-[calc(85vh-160px)]">
{children}
</div>
<HeroModal.Body className="p-6 overflow-y-auto max-h-[calc(85vh-160px)]">{children}</HeroModal.Body>
{/* 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}
</div>
</HeroModal.Footer>
)}
</div>
</div>
</HeroModal.Dialog>
</HeroModal.Container>
</HeroModal.Backdrop>
</HeroModal>
);
};
+136 -101
View File
@@ -1,6 +1,9 @@
"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 {
value: string | number;
@@ -8,158 +11,190 @@ export interface SelectOption {
disabled?: boolean;
}
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
export interface SelectProps {
label?: string;
helperText?: string;
errorMessage?: string;
leftIcon?: React.ReactNode;
options?: SelectOption[];
sizeVariant?: 'sm' | 'md' | 'lg';
sizeVariant?: "sm" | "md" | "lg";
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 = {
sm: {
select: 'py-2 text-xs rounded-lg',
icon: 'text-sm',
leftPad: 'pl-8',
rightPad: 'pr-8',
select: "py-2 text-xs rounded-lg",
icon: "text-sm",
leftPad: "pl-8",
rightPad: "pr-8",
},
md: {
select: 'py-2.5 text-sm rounded-xl',
icon: 'text-base',
leftPad: 'pl-10',
rightPad: 'pr-10',
select: "py-2.5 text-sm rounded-xl",
icon: "text-base",
leftPad: "pl-10",
rightPad: "pr-10",
},
lg: {
select: 'py-3.5 text-base rounded-2xl',
icon: 'text-lg',
leftPad: 'pl-12',
rightPad: 'pr-12',
select: "py-3.5 text-base rounded-2xl",
icon: "text-lg",
leftPad: "pl-12",
rightPad: "pr-12",
},
};
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
export const Select = React.forwardRef<HTMLDivElement, SelectProps>(
(
{
label,
helperText,
errorMessage,
leftIcon,
options,
sizeVariant = 'md',
options = [],
sizeVariant = "md",
fullWidth = true,
id,
name,
disabled,
required,
className = '',
className = "",
value,
defaultValue,
onChange,
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 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 (
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
{/* Label */}
{label && (
<div className="flex items-center justify-between">
<label
htmlFor={selectId}
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'
}`}
>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</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 */}
<select
<div className={`${fullWidth ? "w-full" : "inline-block"} space-y-1.5`}>
<HeroSelect
ref={ref}
id={selectId}
name={name}
disabled={disabled}
required={required}
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 ${
hasError ? "text-rose-600 dark:text-rose-400" : "text-slate-700 dark:text-slate-300"
}`}>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</Label>
)}
<HeroSelect.Trigger
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
border border-slate-300
hover:border-slate-400
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:bg-slate-900/90 dark:text-slate-100
dark:border-slate-800 dark:hover:border-slate-700
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
${leftIcon ? currentSize.leftPad : ''}
${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'
: ''
}
focus:border-cyan-500
focus:ring-4
focus:ring-cyan-500/15
dark:bg-slate-900/90
dark:text-slate-100
dark:border-slate-800
dark:hover:border-slate-700
dark:focus:border-cyan-500
dark:focus:ring-cyan-500/25
disabled:opacity-60
disabled:bg-slate-100
disabled:dark:bg-slate-950
disabled:cursor-not-allowed
${currentSize.select}
${leftIcon ? currentSize.leftPad : ""}
${currentSize.rightPad}
${hasError ? "border-rose-500! ring-rose-500/20! text-rose-900 dark:text-rose-200" : ""}
${className}
`}
{...props}
>
{options
? options.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={opt.disabled}
className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100"
>
{opt.label}
</option>
))
: children}
</select>
`}>
{leftIcon && (
<span
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}
</span>
)}
{/* Right Chevron Icon */}
<div
className={`absolute right-3.5 pointer-events-none flex items-center justify-center text-slate-400 dark:text-slate-500 ${currentSize.icon}`}
>
<HeroSelect.Value className="w-full text-left" />
<HeroSelect.Indicator
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>
</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>
{/* Error or Helper Message */}
{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">
<FiAlertCircle className="shrink-0 text-xs" />
<span>{errorMessage}</span>
</p>
</FieldError>
) : 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}
</HeroSelect>
</div>
);
}
},
);
Select.displayName = 'Select';
Select.displayName = "Select";
+21 -25
View File
@@ -1,45 +1,41 @@
"use client";
import React from 'react';
import React from "react";
import { Spinner as HeroSpinner } from "@heroui/react";
export interface SpinnerProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
color?: 'cyan' | 'blue' | 'white' | 'slate' | 'current';
size?: "xs" | "sm" | "md" | "lg" | "xl";
color?: "cyan" | "blue" | "white" | "slate" | "current";
className?: string;
label?: string;
}
const sizeClasses = {
xs: 'w-3.5 h-3.5 border-2',
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-2',
lg: 'w-8 h-8 border-3',
xl: 'w-12 h-12 border-4',
xs: "w-3.5 h-3.5",
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
xl: "w-12 h-12",
};
const colorClasses = {
cyan: 'border-cyan-500/20 border-t-cyan-500 dark:border-cyan-400/20 dark:border-t-cyan-400',
blue: 'border-blue-500/20 border-t-blue-500 dark:border-blue-400/20 dark:border-t-blue-400',
white: 'border-white/30 border-t-white',
slate: 'border-slate-300 border-t-slate-700 dark:border-slate-700 dark:border-t-slate-200',
current: 'border-current/20 border-t-current',
cyan: "text-cyan-500 dark:text-cyan-400",
blue: "text-blue-500 dark:text-blue-400",
white: "text-white",
slate: "text-slate-700 dark:text-slate-200",
current: "text-current",
};
export const Spinner: React.FC<SpinnerProps> = ({
size = 'md',
color = 'cyan',
className = '',
label = 'Cargando...',
size = "md",
color = "cyan",
className = "",
label = "Cargando...",
}) => {
return (
<div
role="status"
<HeroSpinner
aria-label={label}
className={`inline-flex items-center justify-center ${className}`}
>
<div
className={`rounded-full animate-spin ${sizeClasses[size]} ${colorClasses[color]}`}
className={`inline-block animate-spin ${sizeClasses[size]} ${colorClasses[color]} ${className}`}
/>
<span className="sr-only">{label}</span>
</div>
);
};
+90 -69
View File
@@ -1,5 +1,11 @@
"use client";
import React from 'react';
import React from "react";
import {
Switch as HeroSwitch,
Label,
Description,
} from "@heroui/react";
export interface SwitchProps {
label?: React.ReactNode;
@@ -7,7 +13,7 @@ export interface SwitchProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
size?: "sm" | "md" | "lg";
checkedIcon?: React.ReactNode;
uncheckedIcon?: React.ReactNode;
id?: string;
@@ -16,21 +22,21 @@ export interface SwitchProps {
}
const trackSizes = {
sm: 'w-8 h-4.5',
md: 'w-11 h-6',
lg: 'w-14 h-7.5',
sm: "w-8 h-[18px]",
md: "w-11 h-6",
lg: "w-14 h-[30px]",
};
const thumbSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-5 h-5',
lg: 'w-6.5 h-6.5',
sm: "size-3.5",
md: "size-5",
lg: "size-[26px]",
};
const translateSizes = {
sm: 'translate-x-3.5',
md: 'translate-x-5',
lg: 'translate-x-6.5',
sm: "translate-x-3.5",
md: "translate-x-5",
lg: "translate-x-[26px]",
};
export const Switch: React.FC<SwitchProps> = ({
@@ -39,85 +45,100 @@ export const Switch: React.FC<SwitchProps> = ({
checked = false,
onChange,
disabled = false,
size = 'md',
size = "md",
checkedIcon,
uncheckedIcon,
id,
name,
className = '',
className = "",
}) => {
const switchId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const handleClick = () => {
if (!disabled && onChange) {
onChange(!checked);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
handleClick();
}
};
const switchId =
id ||
name ||
(typeof label === "string"
? label.toLowerCase().replace(/\s+/g, "-")
: undefined);
return (
<div className={`inline-flex items-start gap-3 select-none ${className}`}>
{/* Switch Button */}
<button
<HeroSwitch
id={switchId}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
name={name}
isSelected={checked}
isDisabled={disabled}
size={size}
className={className}
onChange={(e: any) => {
if (!onChange || disabled) return;
const val = typeof e === "boolean" ? e : Boolean(e?.target?.checked);
onChange(val);
}}
>
<HeroSwitch.Content>
<HeroSwitch.Control
className={`
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
disabled:opacity-50 disabled:cursor-not-allowed
${trackSizes[size]}
relative shrink-0 inline-flex items-center rounded-full p-0.5
transition-colors duration-200 ease-in-out cursor-pointer
border-0 m-0 box-border outline-none
${
checked
? 'bg-cyan-500 dark:bg-cyan-500'
: 'bg-slate-300 dark:bg-slate-700 hover:bg-slate-400 dark:hover:bg-slate-600'
? "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" : ""}
`}
>
<HeroSwitch.Thumb
className={`
${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>
{/* TEXTOS */}
{(label || description) && (
<div className="flex flex-col text-left">
{label && (
<Label
className={`
text-xs sm:text-sm font-medium transition-colors cursor-pointer
${
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"
}
`}
>
{/* Thumb */}
<span
{label}
</Label>
)}
{description && (
<Description
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
${thumbSizes[size]}
${checked ? translateSizes[size] : 'translate-x-0'}
text-xs text-slate-500 dark:text-slate-400 cursor-pointer
${disabled ? "cursor-not-allowed" : ""}
`}
>
{checked ? checkedIcon : uncheckedIcon}
</span>
</button>
{/* Label and description */}
{(label || description) && (
<div className="flex flex-col text-left cursor-pointer" onClick={handleClick}>
{label && (
<span
className={`text-xs sm:text-sm font-medium transition-colors ${
disabled
? 'text-slate-400 dark:text-slate-600'
: 'text-slate-800 dark:text-slate-200 hover:text-slate-950 dark:hover:text-white'
}`}
>
{label}
</span>
)}
{description && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{description}
</span>
</Description>
)}
</div>
)}
</div>
</HeroSwitch.Content>
</HeroSwitch>
);
};