Files
SkinbaseNova/resources/js/components/ui/TextInput.jsx
2026-03-28 19:15:39 +01:00

107 lines
2.9 KiB
JavaScript

import React, { forwardRef, useId } from 'react'
/**
* Nova TextInput
*
* @prop {string} label - optional label above field
* @prop {string} error - validation error message
* @prop {string} hint - helper text below field
* @prop {React.ReactNode} leftIcon - icon/element to show inside left side
* @prop {React.ReactNode} rightIcon - icon/element to show inside right side
* @prop {boolean} required - shows red asterisk on label
* @prop {string} size - 'sm' | 'md' | 'lg'
*/
const TextInput = forwardRef(function TextInput(
{
label,
error,
hint,
leftIcon,
rightIcon,
required,
size = 'md',
id,
className = '',
...rest
},
ref,
) {
const generatedId = useId()
const labelSlug = typeof label === 'string'
? label.toLowerCase().replace(/\s+/g, '-')
: null
const inputId = id ?? labelSlug ?? `text-input-${generatedId.replace(/[:]/g, '')}`
const sizeClass = {
sm: 'py-1.5 text-xs',
md: 'py-2.5 text-sm',
lg: 'py-3 text-base',
}[size] ?? 'py-2.5 text-sm'
const paddingLeft = leftIcon ? 'pl-10' : 'pl-3.5'
const paddingRight = rightIcon ? 'pr-10' : 'pr-3.5'
const inputClass = [
'block w-full rounded-xl border bg-white/[0.06] text-white',
'placeholder:text-slate-500',
'transition-all duration-150',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-500/60 focus:border-red-500/70 focus:ring-red-500/40'
: 'border-white/12 hover:border-white/20 focus:border-accent/50 focus:ring-accent/40',
'disabled:opacity-50 disabled:cursor-not-allowed',
sizeClass,
paddingLeft,
paddingRight,
className,
].join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
<div className="relative">
{leftIcon && (
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
{leftIcon}
</span>
)}
<input
id={inputId}
ref={ref}
className={inputClass}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...rest}
/>
{rightIcon && (
<span className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-500">
{rightIcon}
</span>
)}
</div>
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-400">
{error}
</p>
)}
{!error && hint && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
</div>
)
})
export default TextInput