283 lines
7.3 KiB
TypeScript
283 lines
7.3 KiB
TypeScript
/**
|
|
* EN: Input Component (React Aria + Glassmorphism)
|
|
* VI: Component Input (React Aria + Glassmorphism)
|
|
*
|
|
* A fully accessible input component built with React Aria TextField and styled with subtle glassmorphism.
|
|
* Component input có đầy đủ accessibility được build với React Aria TextField và styled với glassmorphism tinh tế.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <Input label="Email" type="email" placeholder="you@example.com" />
|
|
* <Input label="Password" type="password" errorMessage="Invalid password" />
|
|
* <Input label="Name" description="Your full name" />
|
|
* ```
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
TextField as AriaTextField,
|
|
Label,
|
|
Input as AriaInput,
|
|
Text,
|
|
type TextFieldProps as AriaTextFieldProps,
|
|
} from 'react-aria-components';
|
|
import { cn } from '@/shared/utils';
|
|
import { Eye, EyeOff } from 'lucide-react';
|
|
import { Button } from '../button/button';
|
|
|
|
export interface InputProps extends Omit<AriaTextFieldProps, 'children'> {
|
|
/**
|
|
* Label for the input
|
|
*/
|
|
label?: string;
|
|
|
|
/**
|
|
* Helper text shown below the input
|
|
*/
|
|
description?: string;
|
|
|
|
/**
|
|
* Error message shown when input is invalid
|
|
*/
|
|
errorMessage?: string;
|
|
|
|
/**
|
|
* Placeholder text
|
|
*/
|
|
placeholder?: string;
|
|
|
|
/**
|
|
* Input type
|
|
*/
|
|
type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url' | 'number';
|
|
|
|
/**
|
|
* Input variant
|
|
* @default 'glass'
|
|
*/
|
|
variant?: 'glass' | 'solid';
|
|
|
|
/**
|
|
* Custom className for the container
|
|
*/
|
|
className?: string;
|
|
|
|
/**
|
|
* Custom className for the input element
|
|
*/
|
|
inputClassName?: string;
|
|
|
|
/**
|
|
* Left icon/element
|
|
*/
|
|
leftElement?: React.ReactNode;
|
|
|
|
/**
|
|
* Right icon/element
|
|
*/
|
|
rightElement?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Input component built with React Aria TextField
|
|
*
|
|
* Features:
|
|
* - Fully accessible with ARIA attributes
|
|
* - Glassmorphism styling (subtle x.ai style)
|
|
* - Label, description, and error message support
|
|
* - Validation states (invalid, disabled)
|
|
* - Left/right element support (icons, buttons)
|
|
* - Keyboard navigation
|
|
* - Focus states with glass border highlight
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Basic input
|
|
* <Input label="Username" placeholder="Enter username" />
|
|
*
|
|
* // With validation
|
|
* <Input
|
|
* label="Email"
|
|
* type="email"
|
|
* isRequired
|
|
* errorMessage="Please enter a valid email"
|
|
* />
|
|
*
|
|
* // With description
|
|
* <Input
|
|
* label="API Key"
|
|
* description="You can find this in your account settings"
|
|
* />
|
|
*
|
|
* // With icons
|
|
* <Input
|
|
* label="Search"
|
|
* leftElement={<SearchIcon />}
|
|
* rightElement={<ClearButton />}
|
|
* />
|
|
* ```
|
|
*/
|
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
(
|
|
{
|
|
label,
|
|
description,
|
|
errorMessage,
|
|
placeholder,
|
|
type = 'text',
|
|
variant = 'glass',
|
|
className,
|
|
inputClassName,
|
|
leftElement,
|
|
rightElement,
|
|
isInvalid,
|
|
isDisabled,
|
|
isRequired,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
// EN: Internal state for password visibility
|
|
// VI: State nội bộ cho việc hiển thị mật khẩu
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
// EN: Determine the actual input type
|
|
// VI: Xác định type thực tế của input
|
|
const inputType = type === 'password' && showPassword ? 'text' : type;
|
|
|
|
// EN: Determine the right element for password toggle
|
|
// VI: Xác định element bên phải cho nút gạt mật khẩu
|
|
const finalRightElement =
|
|
type === 'password' ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 hover:bg-glass-hover text-text-tertiary"
|
|
onPress={() => setShowPassword(!showPassword)}
|
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
) : (
|
|
rightElement
|
|
);
|
|
|
|
return (
|
|
<AriaTextField
|
|
className={cn('group flex flex-col gap-1.5', className)}
|
|
isInvalid={isInvalid || !!errorMessage}
|
|
isDisabled={isDisabled}
|
|
isRequired={isRequired}
|
|
{...props}
|
|
>
|
|
{label && (
|
|
<Label
|
|
className={cn(
|
|
'text-sm font-medium text-text-secondary',
|
|
'group-data-[disabled]:opacity-50',
|
|
'transition-colors duration-quick'
|
|
)}
|
|
>
|
|
{label}
|
|
{isRequired && <span className="text-accent-error ml-1">*</span>}
|
|
</Label>
|
|
)}
|
|
|
|
<div className="relative flex items-center">
|
|
{leftElement && (
|
|
<div className="absolute left-3 flex items-center pointer-events-none text-text-tertiary">
|
|
{leftElement}
|
|
</div>
|
|
)}
|
|
|
|
<AriaInput
|
|
ref={ref}
|
|
type={inputType}
|
|
placeholder={placeholder}
|
|
className={cn(
|
|
// Base input styles
|
|
variant === 'glass' ? 'glass-input' : 'solid-input',
|
|
'w-full rounded-xl px-3 py-2',
|
|
'text-base text-text-primary placeholder:text-text-tertiary',
|
|
'transition-all duration-quick ease-smooth',
|
|
|
|
// Focus state with X.ai blue ring (X.ai Minimal Design)
|
|
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
|
'focus:ring-accent-primary/30 focus:ring-offset-bg-primary',
|
|
variant === 'glass' && 'focus:bg-glass focus:border-accent-primary',
|
|
variant === 'solid' && 'focus:bg-bg-primary focus:border-accent-primary',
|
|
|
|
// Invalid state
|
|
'data-[invalid]:border-accent-error',
|
|
'data-[invalid]:focus:ring-accent-error/50',
|
|
|
|
// Disabled state
|
|
'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
|
|
|
|
// Left element padding
|
|
leftElement && 'pl-10',
|
|
|
|
// Right element padding
|
|
finalRightElement && 'pr-10',
|
|
|
|
inputClassName
|
|
)}
|
|
/>
|
|
|
|
{finalRightElement && (
|
|
<div className="absolute right-3 flex items-center text-text-tertiary">
|
|
{finalRightElement}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{description && !errorMessage && (
|
|
<Text
|
|
slot="description"
|
|
className={cn(
|
|
'text-sm text-text-tertiary',
|
|
'transition-colors duration-quick'
|
|
)}
|
|
>
|
|
{description}
|
|
</Text>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<Text
|
|
slot="errorMessage"
|
|
className={cn(
|
|
'text-sm text-accent-error',
|
|
'flex items-center gap-1',
|
|
'animate-in fade-in slide-in-from-top-1 duration-quick'
|
|
)}
|
|
>
|
|
<svg
|
|
className="h-4 w-4 flex-shrink-0"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
{errorMessage}
|
|
</Text>
|
|
)}
|
|
</AriaTextField>
|
|
);
|
|
}
|
|
);
|
|
|
|
Input.displayName = 'Input';
|