refactor(ui): migrar componente Select a HeroUI

This commit is contained in:
Miguel Ortega committed 2026-09-14 20:38:55 -05:00
1 parent 0beb12e6ad
commit b56fe10cbe
2 files changed
+141 -107

No files matched your search

+1 -2
View File
@@ -83,8 +83,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
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} {label}
{required}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</Label> </Label>
)} )}
+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";