Files
pos-system/apps/web-client/src/features/shared/components/ui/input/input.tsx

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';