platform-codebase/@packages/@ui/packages/ui-forms/src/FormField.tsx
2026-01-14 10:48:32 -08:00

351 lines
8.6 KiB
TypeScript

/**
* FormField Component
*
* Unified form field component with icon integration for text, email, password,
* textarea, checkbox, and select inputs. Provides consistent styling and error handling.
*/
import { type ReactNode } from 'react'
import { Mail, Lock, User, Building2, MessageSquare } from 'lucide-react'
import styled from 'styled-components'
export interface FormFieldOption {
value: string
label: string
}
export interface FormFieldConfig {
/** Unique field identifier */
id: string
/** Field type */
type: 'text' | 'email' | 'password' | 'textarea' | 'checkbox' | 'select'
/** Field label */
label: string
/** Placeholder text */
placeholder?: string
/** Whether field is required */
required?: boolean
/** Autocomplete attribute */
autoComplete?: string
/** Number of rows (textarea only) */
rows?: number
/** Select options (select only) */
options?: FormFieldOption[]
}
export interface FormFieldProps {
/** Field configuration */
field: FormFieldConfig
/** Current field value */
value: string | boolean
/** Error message */
error?: string
/** Change handler */
onChange: (value: string | boolean) => void
/** Blur handler */
onBlur: () => void
/** Whether field is disabled */
disabled: boolean
/** Custom icon (overrides default) */
customIcon?: ReactNode
/** Render custom content for checkbox label */
renderCheckboxLabel?: (label: string) => ReactNode
}
/**
* Get default icon for a field based on its ID.
*
* Maps common field IDs to appropriate Lucide React icons.
* Can be overridden with customIcon prop.
*/
export function getFieldIcon(field: FormFieldConfig): ReactNode | null {
switch (field.id) {
case 'email':
return <Mail size={18} />
case 'password':
case 'confirmPassword':
return <Lock size={18} />
case 'name':
case 'firstName':
case 'lastName':
return <User size={18} />
case 'company':
case 'organization':
return <Building2 size={18} />
case 'message':
case 'comments':
return <MessageSquare size={18} />
default:
return null
}
}
const FieldWrapper = styled.div<{ $hasError?: boolean }>`
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
&.field-checkbox {
flex-direction: row;
align-items: flex-start;
}
`
const FieldLabel = styled.label`
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.9);
.required-marker {
color: #ff4444;
}
`
const FieldInput = styled.input<{ $hasError?: boolean }>`
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid ${(props: { $hasError?: boolean }) => (props.$hasError ? '#ff4444' : 'rgba(255, 255, 255, 0.2)')};
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
font-size: 0.95rem;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: ${(props: { $hasError?: boolean }) => (props.$hasError ? '#ff4444' : 'rgba(255, 255, 255, 0.4)')};
background: rgba(255, 255, 255, 0.08);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
`
const FieldTextarea = styled.textarea<{ $hasError?: boolean }>`
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid ${(props: { $hasError?: boolean }) => (props.$hasError ? '#ff4444' : 'rgba(255, 255, 255, 0.2)')};
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: ${(props: { $hasError?: boolean }) => (props.$hasError ? '#ff4444' : 'rgba(255, 255, 255, 0.4)')};
background: rgba(255, 255, 255, 0.08);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
`
const FieldSelect = styled.select<{ $hasError?: boolean }>`
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid ${(props: { $hasError?: boolean }) => (props.$hasError ? '#ff4444' : 'rgba(255, 255, 255, 0.2)')};
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: ${(props: { $hasError?: boolean }) => (props.$hasError ? '#ff4444' : 'rgba(255, 255, 255, 0.4)')};
background: rgba(255, 255, 255, 0.08);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
const CheckboxLabel = styled.label`
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
user-select: none;
`
const CheckboxInput = styled.input`
width: 18px;
height: 18px;
margin-top: 0.125rem;
flex-shrink: 0;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
const CheckboxText = styled.span`
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
line-height: 1.5;
`
const FieldError = styled.span`
font-size: 0.85rem;
color: #ff4444;
`
/**
* FormField component with icon integration and consistent styling.
*
* Supports text, email, password, textarea, checkbox, and select inputs.
* Automatically assigns icons based on field ID, or accepts custom icons.
*
* @example
* ```tsx
* const emailField: FormFieldConfig = {
* id: 'email',
* type: 'email',
* label: 'Email Address',
* placeholder: 'you@example.com',
* required: true,
* autoComplete: 'email',
* };
*
* <FormField
* field={emailField}
* value={email}
* error={emailError}
* onChange={setEmail}
* onBlur={validateEmail}
* disabled={false}
* />
* ```
*/
export const FormField = ({
field,
value,
error,
onChange,
onBlur,
disabled,
customIcon,
renderCheckboxLabel,
}: FormFieldProps) => {
const id = `field-${field.id}`
const icon = customIcon ?? getFieldIcon(field)
const hasError = Boolean(error)
if (field.type === 'checkbox') {
return (
<FieldWrapper $hasError={hasError} className="field-checkbox">
<CheckboxLabel>
<CheckboxInput
id={id}
type="checkbox"
checked={value as boolean}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur}
disabled={disabled}
/>
<CheckboxText>
{renderCheckboxLabel ? renderCheckboxLabel(field.label) : field.label}
</CheckboxText>
</CheckboxLabel>
{error && <FieldError>{error}</FieldError>}
</FieldWrapper>
)
}
if (field.type === 'select') {
return (
<FieldWrapper $hasError={hasError}>
<FieldLabel htmlFor={id}>
<span>{field.label}</span>
{field.required && <span className="required-marker">*</span>}
</FieldLabel>
<FieldSelect
id={id}
value={value as string}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={disabled}
required={field.required}
$hasError={hasError}
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</FieldSelect>
{error && <FieldError>{error}</FieldError>}
</FieldWrapper>
)
}
if (field.type === 'textarea') {
return (
<FieldWrapper $hasError={hasError}>
<FieldLabel htmlFor={id}>
{icon}
<span>{field.label}</span>
{field.required && <span className="required-marker">*</span>}
</FieldLabel>
<FieldTextarea
id={id}
value={value as string}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled}
rows={field.rows || 4}
required={field.required}
$hasError={hasError}
/>
{error && <FieldError>{error}</FieldError>}
</FieldWrapper>
)
}
// Text, email, password inputs
return (
<FieldWrapper $hasError={hasError}>
<FieldLabel htmlFor={id}>
{icon}
<span>{field.label}</span>
{field.required && <span className="required-marker">*</span>}
</FieldLabel>
<FieldInput
id={id}
type={field.type}
value={value as string}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled}
autoComplete={field.autoComplete}
required={field.required}
$hasError={hasError}
/>
{error && <FieldError>{error}</FieldError>}
</FieldWrapper>
)
}