2026-01-15 11:31:09 +01:00

124 lines
3.9 KiB
TypeScript

import React, { useMemo } from 'react'
import type { ReactNode } from 'react'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
leftIcon?: ReactNode
rightIcon?: ReactNode
}
function generateId(prefix: string, providedId?: string): string {
if (providedId) {
return providedId
}
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 5)}`
}
function getErrorClasses(error: string | undefined): string {
if (!error) {
return ''
}
return 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50'
}
function getInputClasses(params: {
error: string | undefined
leftIcon: ReactNode | undefined
rightIcon: ReactNode | undefined
className: string
}): string {
const baseClasses = 'block w-full px-3 py-2 border rounded-lg bg-cyber-dark text-cyber-accent placeholder-cyber-accent/50 focus:outline-none focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan transition-colors'
const errorClasses = getErrorClasses(params.error)
const paddingLeft = params.leftIcon ? 'pl-10' : ''
const paddingRight = params.rightIcon ? 'pr-10' : ''
return `${baseClasses} ${errorClasses} ${paddingLeft} ${paddingRight} ${params.className}`.trim()
}
function getAriaDescribedBy(inputId: string, error: string | undefined, helperText: string | undefined): string | undefined {
if (error) {
return `${inputId}-error`
}
if (helperText) {
return `${inputId}-helper`
}
return undefined
}
function InputLabel({ inputId, label }: { inputId: string; label: string }): React.ReactElement {
return (
<label htmlFor={inputId} className="block text-sm font-medium text-cyber-accent mb-1">
{label}
</label>
)
}
function InputIcons({ leftIcon, rightIcon }: { leftIcon?: ReactNode; rightIcon?: ReactNode }): React.ReactElement | null {
if (!leftIcon && !rightIcon) {
return null
}
return (
<>
{leftIcon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-cyber-accent/50">
{leftIcon}
</div>
)}
{rightIcon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-cyber-accent/50">
{rightIcon}
</div>
)}
</>
)
}
function InputError({ inputId, error }: { inputId: string; error: string }): React.ReactElement {
return (
<p id={`${inputId}-error`} className="mt-1 text-sm text-red-400" role="alert">
{error}
</p>
)
}
function InputHelper({ inputId, helperText }: { inputId: string; helperText: string }): React.ReactElement {
return (
<p id={`${inputId}-helper`} className="mt-1 text-sm text-cyber-accent/70">
{helperText}
</p>
)
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, leftIcon, rightIcon, className = '', id, ...props }, ref): React.ReactElement => {
const inputId = useMemo(() => generateId('input', id), [id])
const inputClasses = useMemo(
() => getInputClasses({ error, leftIcon, rightIcon, className }),
[error, leftIcon, rightIcon, className]
)
const ariaDescribedBy = useMemo(() => getAriaDescribedBy(inputId, error, helperText), [inputId, error, helperText])
return (
<div className="w-full">
{label && <InputLabel inputId={inputId} label={label} />}
<div className="relative">
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
<input
ref={ref}
id={inputId}
className={inputClasses}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={ariaDescribedBy}
{...props}
/>
</div>
{error && <InputError inputId={inputId} error={error} />}
{helperText && !error && <InputHelper inputId={inputId} helperText={helperText} />}
</div>
)
}
)
Input.displayName = 'Input'