From ee35f66871e9a239d2737aa785ae67efa51e76ae Mon Sep 17 00:00:00 2001 From: omaima Date: Thu, 26 Feb 2026 13:58:49 +0000 Subject: [PATCH 01/51] polish component states with hover, focus, and disabled styles --- src/components/Checkbox.tsx | 2 +- src/components/Input.tsx | 2 +- src/components/Select.tsx | 2 +- src/components/Textarea.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 4d866a8..c3cae76 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -200,7 +200,7 @@ export const Checkbox = forwardRef( required={required} disabled={disabled} readOnly={readOnly} - className={`formkit-checkbox h-4 w-4 sm:h-5 sm:w-5 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${hasError ? 'formkit-checkbox-error border-red-500' : ''} ${isTouched && isValid ? 'border-green-500' : ''}`} + className={`formkit-checkbox h-4 w-4 sm:h-5 sm:w-5 border border-gray-300 rounded transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:hover:border-gray-300 ${hasError ? 'formkit-checkbox-error border-red-500 focus:ring-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 hover:border-green-400' : ''}`} aria-invalid={hasError} aria-describedby={ [hasError ? errorId : undefined, showHint ? hintId : undefined] diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 2a4b68a..6705c0f 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -188,7 +188,7 @@ export const Input = forwardRef( step={step} pattern={pattern} autoComplete={autoComplete} - className={`formkit-input ${inputClassName} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${hasError ? 'formkit-input-error border-red-500' : ''} ${isTouched && isValid ? 'border-green-500' : ''}`} + className={`formkit-input ${inputClassName} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:border-gray-300 ${hasError ? 'formkit-input-error border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400' : ''}`} aria-invalid={hasError} aria-describedby={ [hasError ? errorId : undefined, showHint ? hintId : undefined] diff --git a/src/components/Select.tsx b/src/components/Select.tsx index b945877..7c38d75 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -209,7 +209,7 @@ export const Select = forwardRef( required={required} disabled={disabled} multiple={multiple} - className={`formkit-select ${selectClassName} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${hasError ? 'formkit-select-error border-red-500' : ''} ${isTouched && isValid ? 'border-green-500' : ''}`} + className={`formkit-select ${selectClassName} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:border-gray-300 ${hasError ? 'formkit-select-error border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400' : ''}`} aria-invalid={hasError} aria-describedby={hasError ? errorId : showHint ? hintId : undefined} > diff --git a/src/components/Textarea.tsx b/src/components/Textarea.tsx index d323943..a96bed9 100644 --- a/src/components/Textarea.tsx +++ b/src/components/Textarea.tsx @@ -213,7 +213,7 @@ export const Textarea = forwardRef( rows={rows} cols={cols} maxLength={maxLength} - className={`formkit-textarea ${textareaClassName} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${hasError ? 'formkit-textarea-error border-red-500' : ''} ${isTouched && isValid ? 'border-green-500' : ''} ${autoResize ? 'formkit-textarea-auto-resize resize-none' : ''}`} + className={`formkit-textarea ${textareaClassName} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:border-gray-300 ${hasError ? 'formkit-textarea-error border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400' : ''} ${autoResize ? 'formkit-textarea-auto-resize resize-none' : ''}`} aria-invalid={hasError} aria-describedby={ [ From 92d53934f3b1761f0cca2f642fe377e0c969f3df Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 13:26:00 +0000 Subject: [PATCH 02/51] add useForm hook and FormContext for form state management --- src/hooks/FormContext.ts | 37 ++ src/hooks/__tests__/useForm.test.ts | 749 ++++++++++++++++++++++++++++ src/hooks/useForm.ts | 486 ++++++++++++++++++ 3 files changed, 1272 insertions(+) create mode 100644 src/hooks/FormContext.ts create mode 100644 src/hooks/__tests__/useForm.test.ts create mode 100644 src/hooks/useForm.ts diff --git a/src/hooks/FormContext.ts b/src/hooks/FormContext.ts new file mode 100644 index 0000000..e737f61 --- /dev/null +++ b/src/hooks/FormContext.ts @@ -0,0 +1,37 @@ +/** + * Form Context for sharing form state with child components + */ + +import { createContext, useContext } from 'react'; +import type { UseFormReturn, FormValues } from '../hooks/useForm'; + +/** + * Form context value type + */ +export type FormContextValue = UseFormReturn | null; + +/** + * Form context for sharing form state with child fields + */ +export const FormContext = createContext(null); + +/** + * Hook to access form context + * @returns Form context value or null if not within a Form + */ +export function useFormContext(): UseFormReturn | null { + return useContext(FormContext) as UseFormReturn | null; +} + +/** + * Hook to access form context (throws if not within a Form) + * @returns Form context value + * @throws Error if not within a Form component + */ +export function useFormContextStrict(): UseFormReturn { + const context = useFormContext(); + if (!context) { + throw new Error('useFormContextStrict must be used within a Form component'); + } + return context; +} diff --git a/src/hooks/__tests__/useForm.test.ts b/src/hooks/__tests__/useForm.test.ts new file mode 100644 index 0000000..a65abd5 --- /dev/null +++ b/src/hooks/__tests__/useForm.test.ts @@ -0,0 +1,749 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useForm } from '../useForm'; + +describe('useForm', () => { + describe('initialization', () => { + it('initializes with default state', () => { + const { result } = renderHook(() => useForm()); + + expect(result.current.formState.isSubmitting).toBe(false); + expect(result.current.formState.isSubmitted).toBe(false); + expect(result.current.formState.submitCount).toBe(0); + expect(result.current.formState.isValid).toBe(true); + expect(result.current.formState.isValidating).toBe(false); + expect(result.current.formState.isDirty).toBe(false); + expect(result.current.formState.errors).toEqual({}); + expect(result.current.formState.touched).toEqual({}); + }); + + it('initializes with default values', () => { + const defaultValues = { email: 'test@example.com', name: 'John' }; + const { result } = renderHook(() => useForm({ defaultValues })); + + expect(result.current.formState.values).toEqual(defaultValues); + }); + + it('provides form props', () => { + const { result } = renderHook(() => useForm()); + + expect(result.current.formProps).toHaveProperty('onSubmit'); + expect(result.current.formProps.noValidate).toBe(true); + }); + }); + + describe('field registration', () => { + it('registers a field', () => { + const { result } = renderHook(() => useForm()); + + const mockField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + expect(result.current.getValues()).toEqual({ email: 'test@example.com' }); + }); + + it('unregisters a field', () => { + const { result } = renderHook(() => useForm()); + + const mockField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.unregister('email'); + }); + + expect(result.current.getValues()).toEqual({}); + }); + + it('sets initial value from defaultValues on register', () => { + const { result } = renderHook(() => + useForm({ defaultValues: { email: 'default@example.com' } }), + ); + + const setValue = vi.fn(); + const mockField = { + name: 'email', + getValue: () => '', + setValue, + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + expect(setValue).toHaveBeenCalledWith('default@example.com'); + }); + }); + + describe('getValue and getValues', () => { + it('gets a single field value', () => { + const { result } = renderHook(() => useForm()); + + const mockField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + expect(result.current.getValue('email')).toBe('test@example.com'); + }); + + it('returns undefined for unregistered field', () => { + const { result } = renderHook(() => useForm()); + + expect(result.current.getValue('nonexistent')).toBeUndefined(); + }); + }); + + describe('setValue', () => { + it('sets a field value', () => { + const { result } = renderHook(() => useForm()); + + const setValue = vi.fn(); + const mockField = { + name: 'email', + getValue: () => 'updated@example.com', + setValue, + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.setValue('email', 'updated@example.com'); + }); + + expect(setValue).toHaveBeenCalledWith('updated@example.com'); + }); + + it('touches field when shouldTouch is true', () => { + const { result } = renderHook(() => useForm()); + + const setTouched = vi.fn(); + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched, + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.setValue('email', 'new value', { shouldTouch: true }); + }); + + expect(setTouched).toHaveBeenCalledWith(true); + }); + + it('validates field when shouldValidate is true', async () => { + const { result } = renderHook(() => useForm()); + + const validate = vi.fn().mockResolvedValue(null); + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate, + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + await act(async () => { + result.current.setValue('email', 'new value', { shouldValidate: true }); + }); + + await waitFor(() => { + expect(validate).toHaveBeenCalled(); + }); + }); + }); + + describe('setValues', () => { + it('sets multiple field values', () => { + const { result } = renderHook(() => useForm()); + + const setEmailValue = vi.fn(); + const setNameValue = vi.fn(); + + const emailField = { + name: 'email', + getValue: () => '', + setValue: setEmailValue, + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + const nameField = { + name: 'name', + getValue: () => '', + setValue: setNameValue, + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', emailField); + result.current.register('name', nameField); + }); + + act(() => { + result.current.setValues({ email: 'test@example.com', name: 'John' }); + }); + + expect(setEmailValue).toHaveBeenCalledWith('test@example.com'); + expect(setNameValue).toHaveBeenCalledWith('John'); + }); + }); + + describe('error handling', () => { + it('sets a field error', () => { + const { result } = renderHook(() => useForm()); + + const setError = vi.fn(); + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError, + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.setError('email', 'Invalid email'); + }); + + expect(setError).toHaveBeenCalledWith('Invalid email'); + expect(result.current.formState.errors).toEqual({ email: 'Invalid email' }); + }); + + it('clears a field error', () => { + const { result } = renderHook(() => useForm()); + + const setError = vi.fn(); + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError, + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.setError('email', 'Invalid email'); + }); + + act(() => { + result.current.clearError('email'); + }); + + expect(result.current.formState.errors).toEqual({ email: null }); + }); + + it('clears all errors', () => { + const { result } = renderHook(() => useForm()); + + const emailSetError = vi.fn(); + const nameSetError = vi.fn(); + + const emailField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: emailSetError, + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + const nameField = { + name: 'name', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: nameSetError, + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', emailField); + result.current.register('name', nameField); + }); + + act(() => { + result.current.setError('email', 'Invalid email'); + result.current.setError('name', 'Required'); + }); + + act(() => { + result.current.clearErrors(); + }); + + expect(result.current.formState.errors).toEqual({}); + }); + }); + + describe('validation', () => { + it('triggers validation for all fields', async () => { + const { result } = renderHook(() => useForm()); + + const emailValidate = vi.fn().mockResolvedValue(null); + const nameValidate = vi.fn().mockResolvedValue(null); + + const emailField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: emailValidate, + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + const nameField = { + name: 'name', + getValue: () => 'John', + setValue: vi.fn(), + validate: nameValidate, + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', emailField); + result.current.register('name', nameField); + }); + + let isValid: boolean = false; + await act(async () => { + isValid = await result.current.trigger(); + }); + + expect(emailValidate).toHaveBeenCalled(); + expect(nameValidate).toHaveBeenCalled(); + expect(isValid).toBe(true); + }); + + it('triggers validation for specific fields', async () => { + const { result } = renderHook(() => useForm()); + + const emailValidate = vi.fn().mockResolvedValue(null); + const nameValidate = vi.fn().mockResolvedValue(null); + + const emailField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: emailValidate, + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + const nameField = { + name: 'name', + getValue: () => 'John', + setValue: vi.fn(), + validate: nameValidate, + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', emailField); + result.current.register('name', nameField); + }); + + await act(async () => { + await result.current.trigger('email'); + }); + + expect(emailValidate).toHaveBeenCalled(); + expect(nameValidate).not.toHaveBeenCalled(); + }); + + it('returns false when validation fails', async () => { + const { result } = renderHook(() => useForm()); + + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue('Email is required'), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + let isValid: boolean = true; + await act(async () => { + isValid = await result.current.trigger(); + }); + + expect(isValid).toBe(false); + expect(result.current.formState.errors).toEqual({ email: 'Email is required' }); + }); + + it('runs form-level validators', async () => { + const formValidator = vi.fn().mockResolvedValue({ password: 'Passwords must match' }); + + const { result } = renderHook(() => useForm({ validators: [formValidator] })); + + const passwordField = { + name: 'password', + getValue: () => 'pass123', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('password', passwordField); + }); + + await act(async () => { + await result.current.trigger(); + }); + + expect(formValidator).toHaveBeenCalled(); + }); + }); + + describe('reset', () => { + it('resets form to initial state', () => { + const { result } = renderHook(() => + useForm({ defaultValues: { email: 'initial@example.com' } }), + ); + + const reset = vi.fn(); + const setValue = vi.fn(); + const mockField = { + name: 'email', + getValue: () => 'changed@example.com', + setValue, + validate: vi.fn().mockResolvedValue(null), + reset, + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => true, + isDirty: () => true, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.setError('email', 'Some error'); + }); + + act(() => { + result.current.reset(); + }); + + expect(reset).toHaveBeenCalled(); + expect(result.current.formState.errors).toEqual({}); + expect(result.current.formState.touched).toEqual({}); + expect(result.current.formState.isSubmitted).toBe(false); + expect(result.current.formState.submitCount).toBe(0); + }); + + it('resets form with new values', () => { + const { result } = renderHook(() => + useForm<{ email: string }>({ defaultValues: { email: 'initial@example.com' } }), + ); + + const reset = vi.fn(); + const setValue = vi.fn(); + const mockField = { + name: 'email', + getValue: () => '', + setValue, + validate: vi.fn().mockResolvedValue(null), + reset, + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + act(() => { + result.current.reset({ email: 'new@example.com' }); + }); + + expect(setValue).toHaveBeenCalledWith('new@example.com'); + }); + }); + + describe('handleSubmit', () => { + it('calls onValid when form is valid', async () => { + const { result } = renderHook(() => useForm()); + const onValid = vi.fn(); + + const mockField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + const submitHandler = result.current.handleSubmit(onValid); + + await act(async () => { + await submitHandler(); + }); + + expect(onValid).toHaveBeenCalledWith({ email: 'test@example.com' }); + expect(result.current.formState.isSubmitted).toBe(true); + expect(result.current.formState.submitCount).toBe(1); + }); + + it('calls onInvalid when form is invalid', async () => { + const { result } = renderHook(() => useForm()); + const onValid = vi.fn(); + const onInvalid = vi.fn(); + + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue('Email is required'), + reset: vi.fn(), + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + const submitHandler = result.current.handleSubmit(onValid, onInvalid); + + await act(async () => { + await submitHandler(); + }); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalled(); + }); + + it('touches all fields on submit', async () => { + const { result } = renderHook(() => useForm()); + + const setTouched = vi.fn(); + const mockField = { + name: 'email', + getValue: () => '', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset: vi.fn(), + setError: vi.fn(), + setTouched, + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + const submitHandler = result.current.handleSubmit(() => {}); + + await act(async () => { + await submitHandler(); + }); + + expect(setTouched).toHaveBeenCalledWith(true); + expect(result.current.formState.touched).toEqual({ email: true }); + }); + + it('prevents default form event', async () => { + const { result } = renderHook(() => useForm()); + const onValid = vi.fn(); + + const mockEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as React.FormEvent; + + const submitHandler = result.current.handleSubmit(onValid); + + await act(async () => { + await submitHandler(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + }); + + it('resets form after successful submit when configured', async () => { + const { result } = renderHook(() => useForm({ resetOnSuccessfulSubmit: true })); + + const reset = vi.fn(); + const mockField = { + name: 'email', + getValue: () => 'test@example.com', + setValue: vi.fn(), + validate: vi.fn().mockResolvedValue(null), + reset, + setError: vi.fn(), + setTouched: vi.fn(), + isTouched: () => false, + isDirty: () => false, + }; + + act(() => { + result.current.register('email', mockField); + }); + + const submitHandler = result.current.handleSubmit(() => {}); + + await act(async () => { + await submitHandler(); + }); + + expect(reset).toHaveBeenCalled(); + }); + }); + + describe('formState', () => { + it('correctly computes isValid', () => { + const { result } = renderHook(() => useForm()); + + expect(result.current.formState.isValid).toBe(true); + + act(() => { + result.current.setError('email', 'Invalid email'); + }); + + expect(result.current.formState.isValid).toBe(false); + }); + }); +}); diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts new file mode 100644 index 0000000..a66992b --- /dev/null +++ b/src/hooks/useForm.ts @@ -0,0 +1,486 @@ +/** + * useForm hook for managing form state and validation + */ + +import { useState, useCallback, useMemo, useRef } from 'react'; +import type { FieldValue } from '../utils/types'; + +/** + * Form field registration info + */ +export interface FieldRegistration { + /** Field name */ + name: string; + /** Get current field value */ + getValue: () => FieldValue; + /** Set field value */ + setValue: (value: FieldValue) => void; + /** Validate field */ + validate: () => Promise; + /** Reset field to initial state */ + reset: () => void; + /** Set field error */ + setError: (error: string | null) => void; + /** Set field touched state */ + setTouched: (touched: boolean) => void; + /** Get field touched state */ + isTouched: () => boolean; + /** Get field dirty state */ + isDirty: () => boolean; +} + +/** + * Form values type + */ +export type FormValues = Record; + +/** + * Form field errors type (simple string map) + */ +export type FormFieldErrors = Record; + +/** + * Form touched state type + */ +export type FormTouched = Record; + +/** + * Form validation mode + */ +export type ValidationMode = 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all'; + +/** + * Form-level validator function + */ +export type FormValidator = (values: FormValues) => FormFieldErrors | Promise; + +/** + * Configuration options for useForm hook + */ +export interface UseFormOptions { + /** Initial form values */ + defaultValues?: Partial; + /** Validation mode */ + mode?: ValidationMode; + /** Revalidation mode after submit */ + reValidateMode?: Exclude; + /** Form-level validators */ + validators?: FormValidator[]; + /** Should focus first error on submit */ + shouldFocusError?: boolean; + /** Delay before validation (ms) */ + validationDelay?: number; + /** Reset form after successful submit */ + resetOnSuccessfulSubmit?: boolean; +} + +/** + * Form state + */ +export interface FormState { + /** Whether form is currently submitting */ + isSubmitting: boolean; + /** Whether form has been submitted at least once */ + isSubmitted: boolean; + /** Number of submit attempts */ + submitCount: number; + /** Whether form is valid */ + isValid: boolean; + /** Whether form is validating */ + isValidating: boolean; + /** Whether any field is dirty */ + isDirty: boolean; + /** Current form errors */ + errors: FormFieldErrors; + /** Touched state for all fields */ + touched: FormTouched; + /** Current form values */ + values: FormValues; +} + +/** + * Return type of useForm hook + */ +export interface UseFormReturn { + /** Current form state */ + formState: FormState; + /** Register a field with the form */ + register: (name: string, registration: FieldRegistration) => void; + /** Unregister a field from the form */ + unregister: (name: string) => void; + /** Get all form values */ + getValues: () => T; + /** Get a single field value */ + getValue: (name: K) => T[K]; + /** Set a single field value */ + setValue: ( + name: K, + value: T[K], + options?: { shouldValidate?: boolean; shouldTouch?: boolean }, + ) => void; + /** Set multiple field values */ + setValues: (values: Partial, options?: { shouldValidate?: boolean }) => void; + /** Set a field error */ + setError: (name: keyof T, error: string | null) => void; + /** Clear a field error */ + clearError: (name: keyof T) => void; + /** Clear all errors */ + clearErrors: () => void; + /** Trigger validation for specific fields or all fields */ + trigger: (names?: (keyof T)[] | keyof T) => Promise; + /** Reset form to initial state */ + reset: (values?: Partial) => void; + /** Handle form submission */ + handleSubmit: ( + onValid: (data: T) => void | Promise, + onInvalid?: (errors: FormFieldErrors) => void, + ) => (e?: React.FormEvent) => Promise; + /** Get props for the form element */ + formProps: { + onSubmit: (e: React.FormEvent) => void; + noValidate: boolean; + }; +} + +/** + * Hook for managing form state and validation + * @param options - Form configuration options + * @returns Form state and control methods + */ +export function useForm( + options: UseFormOptions = {}, +): UseFormReturn { + const { + defaultValues = {} as Partial, + // mode and reValidateMode reserved for future use + // mode = 'onSubmit', + // reValidateMode = 'onChange', + validators = [], + shouldFocusError = true, + resetOnSuccessfulSubmit = false, + } = options; + + // Field registrations + const fieldsRef = useRef>(new Map()); + + // Form state + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [submitCount, setSubmitCount] = useState(0); + const [isValidating, setIsValidating] = useState(false); + const [errors, setErrors] = useState({}); + const [touched, setTouched] = useState({}); + const [values, setValues] = useState(defaultValues as FormValues); + + // Computed states + const isValid = useMemo(() => Object.values(errors).every((e) => !e), [errors]); + const isDirty = useMemo(() => { + for (const field of fieldsRef.current.values()) { + if (field.isDirty()) return true; + } + return false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values]); + + // Register a field + const register = useCallback( + (name: string, registration: FieldRegistration) => { + fieldsRef.current.set(name, registration); + // Set initial value if provided in defaultValues + if (name in defaultValues) { + registration.setValue(defaultValues[name as keyof T] as FieldValue); + } + }, + [defaultValues], + ); + + // Unregister a field + const unregister = useCallback((name: string) => { + fieldsRef.current.delete(name); + setErrors((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + setTouched((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + setValues((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + }, []); + + // Get all form values + const getValues = useCallback((): T => { + const result: FormValues = {}; + for (const [name, field] of fieldsRef.current) { + result[name] = field.getValue(); + } + return result as T; + }, []); + + // Get a single field value + const getValue = useCallback((name: K): T[K] => { + const field = fieldsRef.current.get(name as string); + return (field ? field.getValue() : undefined) as T[K]; + }, []); + + // Set a single field value + const setValue = useCallback( + ( + name: K, + value: T[K], + opts: { shouldValidate?: boolean; shouldTouch?: boolean } = {}, + ) => { + const field = fieldsRef.current.get(name as string); + if (field) { + field.setValue(value as FieldValue); + setValues((prev) => ({ ...prev, [name]: value })); + + if (opts.shouldTouch) { + field.setTouched(true); + setTouched((prev) => ({ ...prev, [name]: true })); + } + + if (opts.shouldValidate) { + field.validate().then((error) => { + setErrors((prev) => ({ ...prev, [name]: error })); + }); + } + } + }, + [], + ); + + // Trigger validation + const trigger = useCallback( + async (names?: (keyof T)[] | keyof T): Promise => { + setIsValidating(true); + + const fieldsToValidate = names + ? (Array.isArray(names) ? names : [names]) + .map((n) => fieldsRef.current.get(n as string)) + .filter(Boolean) + : Array.from(fieldsRef.current.values()); + + const validationResults = await Promise.all( + fieldsToValidate.map(async (field) => { + if (!field) return { name: '', error: null }; + const error = await field.validate(); + return { name: field.name, error }; + }), + ); + + // Run form-level validators + const currentValues = getValues(); + const formLevelErrors: FormFieldErrors = {}; + + for (const validator of validators) { + const result = await validator(currentValues); + Object.assign(formLevelErrors, result); + } + + // Merge field and form-level errors + const newErrors: FormFieldErrors = { ...errors }; + for (const { name, error } of validationResults) { + if (name) { + newErrors[name] = error ?? formLevelErrors[name] ?? null; + } + } + + // Add any remaining form-level errors for fields not validated + for (const [name, error] of Object.entries(formLevelErrors)) { + if (!(name in newErrors)) { + newErrors[name] = error; + } + } + + setErrors(newErrors); + setIsValidating(false); + + return Object.values(newErrors).every((e) => !e); + }, + [errors, getValues, validators], + ); + + // Set multiple field values + const setValuesMultiple = useCallback( + (newValues: Partial, opts: { shouldValidate?: boolean } = {}) => { + for (const [name, value] of Object.entries(newValues)) { + const field = fieldsRef.current.get(name); + if (field) { + field.setValue(value as FieldValue); + } + } + setValues((prev) => ({ ...prev, ...newValues })); + + if (opts.shouldValidate) { + trigger(Object.keys(newValues) as (keyof T)[]); + } + }, + [trigger], + ); + + // Set a field error + const setError = useCallback((name: keyof T, error: string | null) => { + const field = fieldsRef.current.get(name as string); + if (field) { + field.setError(error); + } + setErrors((prev) => ({ ...prev, [name]: error })); + }, []); + + // Clear a field error + const clearError = useCallback( + (name: keyof T) => { + setError(name, null); + }, + [setError], + ); + + // Clear all errors + const clearErrors = useCallback(() => { + for (const field of fieldsRef.current.values()) { + field.setError(null); + } + setErrors({}); + }, []); + + // Reset form + const reset = useCallback( + (newValues?: Partial) => { + const resetValues = newValues ?? defaultValues; + + for (const field of fieldsRef.current.values()) { + field.reset(); + if (field.name in resetValues) { + field.setValue(resetValues[field.name as keyof T] as FieldValue); + } + } + + setValues(resetValues as FormValues); + setErrors({}); + setTouched({}); + setIsSubmitted(false); + setSubmitCount(0); + }, + [defaultValues], + ); + + // Focus first error field + const focusFirstError = useCallback(() => { + if (!shouldFocusError) return; + + for (const [name] of fieldsRef.current) { + if (errors[name]) { + const element = document.querySelector(`[name="${name}"]`) as HTMLElement; + if (element && typeof element.focus === 'function') { + element.focus(); + break; + } + } + } + }, [errors, shouldFocusError]); + + // Handle form submission + const handleSubmit = useCallback( + (onValid: (data: T) => void | Promise, onInvalid?: (errors: FormFieldErrors) => void) => { + return async (e?: React.FormEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + setIsSubmitting(true); + setSubmitCount((prev) => prev + 1); + + // Touch all fields + for (const field of fieldsRef.current.values()) { + field.setTouched(true); + } + setTouched( + Object.fromEntries(Array.from(fieldsRef.current.keys()).map((name) => [name, true])), + ); + + // Validate all fields + const isFormValid = await trigger(); + + setIsSubmitted(true); + + if (isFormValid) { + try { + await onValid(getValues()); + if (resetOnSuccessfulSubmit) { + reset(); + } + } catch (error) { + // Allow onValid to throw and keep form in submitted state + console.error('Form submission error:', error); + } + } else { + focusFirstError(); + onInvalid?.(errors); + } + + setIsSubmitting(false); + }; + }, + [trigger, getValues, errors, reset, resetOnSuccessfulSubmit, focusFirstError], + ); + + // Form props for the form element + const formProps = useMemo( + () => ({ + onSubmit: handleSubmit(() => {}), + noValidate: true, + }), + [handleSubmit], + ); + + // Form state object + const formState: FormState = useMemo( + () => ({ + isSubmitting, + isSubmitted, + submitCount, + isValid, + isValidating, + isDirty, + errors, + touched, + values, + }), + [ + isSubmitting, + isSubmitted, + submitCount, + isValid, + isValidating, + isDirty, + errors, + touched, + values, + ], + ); + + return { + formState, + register, + unregister, + getValues, + getValue, + setValue, + setValues: setValuesMultiple, + setError, + clearError, + clearErrors, + trigger, + reset, + handleSubmit, + formProps, + }; +} From 0f17c772da6eaad7a8137eb2227d461e2226424f Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 13:27:33 +0000 Subject: [PATCH 03/51] add Form wrapper component with submit handling --- src/components/Form.tsx | 104 +++++++++++ src/components/__tests__/Form.test.tsx | 231 +++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/components/Form.tsx create mode 100644 src/components/__tests__/Form.test.tsx diff --git a/src/components/Form.tsx b/src/components/Form.tsx new file mode 100644 index 0000000..ec0d6d4 --- /dev/null +++ b/src/components/Form.tsx @@ -0,0 +1,104 @@ +/** + * Form component with integrated form state management + */ + +import React, { forwardRef, useMemo } from 'react'; +import { FormContext } from '../hooks/FormContext'; +import { + useForm, + type UseFormOptions, + type FormValues, + type FormFieldErrors, +} from '../hooks/useForm'; + +/** + * Props for Form component + */ +export interface FormProps extends Omit< + React.FormHTMLAttributes, + 'onSubmit' | 'onError' +> { + /** Child elements (optional if using render prop) */ + children?: React.ReactNode; + /** Form configuration options */ + formOptions?: UseFormOptions; + /** Submit handler called when form is valid */ + onSubmit?: (data: T) => void | Promise; + /** Error handler called when form validation fails */ + onError?: (errors: FormFieldErrors) => void; + /** Custom CSS class name */ + className?: string; + /** Whether to disable native HTML validation */ + noValidate?: boolean; + /** Render prop for accessing form state */ + render?: (form: ReturnType>) => React.ReactNode; +} + +/** + * Form component that provides form state management to child components + * + * @example + * ```tsx + *
console.log(data)} + * onError={(errors) => console.log(errors)} + * > + * + * + *
+ * ``` + */ +function FormInner( + props: FormProps, + ref: React.ForwardedRef, +) { + const { + children, + formOptions = {}, + onSubmit, + onError, + className = '', + noValidate = true, + render, + ...rest + } = props; + + // Initialize form state + const form = useForm(formOptions); + + // Memoize submit handler + const handleSubmit = useMemo(() => { + if (!onSubmit) { + return form.handleSubmit(() => {}, onError); + } + return form.handleSubmit(onSubmit, onError); + }, [form, onSubmit, onError]); + + // Render content + const content = render ? render(form) : children; + + return ( + }> +
+ {content} +
+
+ ); +} + +/** + * Form component with forwardRef support + */ +export const Form = forwardRef(FormInner) as ( + props: FormProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; + +// Set display name for debugging +(Form as React.FC).displayName = 'Form'; diff --git a/src/components/__tests__/Form.test.tsx b/src/components/__tests__/Form.test.tsx new file mode 100644 index 0000000..3ec7374 --- /dev/null +++ b/src/components/__tests__/Form.test.tsx @@ -0,0 +1,231 @@ +/** + * Tests for Form component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Form } from '../Form'; +import { useFormContext } from '../../hooks/FormContext'; + +// Test component that accesses form context +function TestContextConsumer() { + const form = useFormContext(); + return
{form ? 'Context available' : 'No context'}
; +} + +describe('Form', () => { + describe('basic rendering', () => { + it('renders form element', () => { + render( +
+
Form content
+
, + ); + const form = document.querySelector('form'); + expect(form).toBeInTheDocument(); + }); + + it('renders children', () => { + render( +
+ + +
, + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render( +
+
Content
+
, + ); + const form = document.querySelector('form'); + expect(form).toHaveClass('custom-form'); + expect(form).toHaveClass('formkit-form'); + }); + + it('sets noValidate by default', () => { + render( +
+
Content
+
, + ); + const form = document.querySelector('form'); + expect(form).toHaveAttribute('noValidate'); + }); + + it('allows overriding noValidate', () => { + render( +
+
Content
+
, + ); + const form = document.querySelector('form'); + expect(form).not.toHaveAttribute('noValidate'); + }); + }); + + describe('form context', () => { + it('provides form context to children', () => { + render( +
+ + , + ); + expect(screen.getByTestId('context-consumer')).toHaveTextContent('Context available'); + }); + }); + + describe('ref forwarding', () => { + it('forwards ref to form element', () => { + const ref = vi.fn(); + render( +
+
Content
+
, + ); + expect(ref).toHaveBeenCalled(); + expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLFormElement); + }); + }); + + describe('form submission', () => { + it('prevents default form submission', async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + // Form should not reload page (onSubmit was called, not native submit) + expect(handleSubmit).toHaveBeenCalled(); + }); + + it('calls onSubmit with form data on valid submit', async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + }); + + it('calls onError when form is invalid', async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + const handleError = vi.fn(); + + // This test would need a registered field with validation + // For basic test, we just verify the form renders and submits + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + // Without registered fields with errors, onSubmit should be called + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalled(); + }); + }); + }); + + describe('render prop', () => { + it('supports render prop pattern', () => { + render( +
( +
+ Form state: {form.formState.isSubmitting ? 'submitting' : 'idle'} +
+ )} + />, + ); + + expect(screen.getByTestId('render-content')).toHaveTextContent('Form state: idle'); + }); + + it('provides form state in render prop', () => { + render( + ( +
+ Is valid: {form.formState.isValid ? 'yes' : 'no'} +
+ )} + />, + ); + + expect(screen.getByTestId('render-content')).toHaveTextContent('Is valid: yes'); + }); + }); + + describe('form options', () => { + it('accepts formOptions prop', () => { + render( + +
Content
+
, + ); + + const form = document.querySelector('form'); + expect(form).toBeInTheDocument(); + }); + }); + + describe('HTML form attributes', () => { + it('passes through HTML form attributes', () => { + render( +
+
Content
+
, + ); + + const form = screen.getByTestId('test-form'); + expect(form).toHaveAttribute('id', 'my-form'); + expect(form).toHaveAttribute('autoComplete', 'off'); + }); + + it('supports method attribute', () => { + render( +
+
Content
+
, + ); + + const form = document.querySelector('form'); + expect(form).toHaveAttribute('method', 'post'); + }); + + it('supports action attribute', () => { + render( +
+
Content
+
, + ); + + const form = document.querySelector('form'); + expect(form).toHaveAttribute('action', '/submit'); + }); + }); +}); From 7118c2c294fdb738e582f257ec75dd97c3f07c43 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 13:28:05 +0000 Subject: [PATCH 04/51] export Form, useForm, and FormContext from package --- src/components/index.ts | 1 + src/hooks/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/components/index.ts b/src/components/index.ts index 3d7cd16..7cf32db 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './Form'; export * from './Input'; export * from './Textarea'; export * from './Select'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dc86e80..35788b9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,5 @@ +export * from './useForm'; +export * from './FormContext'; export * from './useFormField'; export * from './useValidation'; export * from './useFieldError'; From cd3d0b4f8f17f83c26c974e0e535cd37ed6b381a Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 13:28:43 +0000 Subject: [PATCH 05/51] increase timeout for autoDismiss test stability --- src/hooks/__tests__/useFieldError.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/__tests__/useFieldError.test.ts b/src/hooks/__tests__/useFieldError.test.ts index a84e7f0..246bd0d 100644 --- a/src/hooks/__tests__/useFieldError.test.ts +++ b/src/hooks/__tests__/useFieldError.test.ts @@ -230,7 +230,7 @@ describe('useFieldError', () => { describe('autoDismiss', () => { it('auto-dismisses error after delay', async () => { - const { result } = renderHook(() => useFieldError({ autoDismiss: 100 })); + const { result } = renderHook(() => useFieldError({ autoDismiss: 50 })); act(() => { result.current.setError('Auto-dismiss error'); @@ -242,7 +242,7 @@ describe('useFieldError', () => { () => { expect(result.current.error).toBe(null); }, - { timeout: 200 }, + { timeout: 300 }, ); }); From e946f541484d8b98414e8e008f8e23ab4308d005 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 13:48:47 +0000 Subject: [PATCH 06/51] add PasswordInput component with visibility toggle and strength meter --- src/components/PasswordInput.tsx | 434 ++++++++++++++++++ .../__tests__/PasswordInput.test.tsx | 381 +++++++++++++++ src/components/index.ts | 1 + 3 files changed, 816 insertions(+) create mode 100644 src/components/PasswordInput.tsx create mode 100644 src/components/__tests__/PasswordInput.test.tsx diff --git a/src/components/PasswordInput.tsx b/src/components/PasswordInput.tsx new file mode 100644 index 0000000..9665376 --- /dev/null +++ b/src/components/PasswordInput.tsx @@ -0,0 +1,434 @@ +/** + * PasswordInput component with visibility toggle and strength meter + */ + +import { forwardRef, useId, useEffect, useState, useCallback, useMemo } from 'react'; +import type { ValidationRule } from '../validation/types'; +import { useFormField } from '../hooks/useFormField'; +import { useValidation } from '../hooks/useValidation'; +import { useFieldError } from '../hooks/useFieldError'; + +/** + * Password strength levels + */ +export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong'; + +/** + * Password strength result + */ +export interface PasswordStrengthResult { + /** Strength level */ + strength: PasswordStrength; + /** Numeric score (0-100) */ + score: number; + /** Feedback suggestions */ + feedback: string[]; +} + +/** + * Props for PasswordInput component + */ +export interface PasswordInputProps { + /** Input name attribute */ + name: string; + /** Label text */ + label?: string; + /** Placeholder text */ + placeholder?: string; + /** Default value */ + defaultValue?: string; + /** Whether field is required */ + required?: boolean; + /** Whether field is disabled */ + disabled?: boolean; + /** Whether field is read-only */ + readOnly?: boolean; + /** Custom CSS class name for the container */ + className?: string; + /** Custom CSS class name for the input element */ + inputClassName?: string; + /** Validation rules */ + validationRules?: ValidationRule[]; + /** When to validate */ + validateOn?: 'change' | 'blur' | 'submit'; + /** Debounce validation (ms) */ + debounce?: number; + /** Show error message */ + showError?: boolean; + /** Auto-dismiss errors after delay (ms) */ + autoDismissError?: number; + /** Maximum length */ + maxLength?: number; + /** Autocomplete attribute */ + autoComplete?: 'current-password' | 'new-password' | 'off'; + /** Hint or help text */ + hint?: string; + /** Show password visibility toggle */ + showToggle?: boolean; + /** Show password strength meter */ + showStrengthMeter?: boolean; + /** Custom strength calculator */ + strengthCalculator?: (password: string) => PasswordStrengthResult; + /** Change handler */ + onChange?: (value: string) => void; + /** Blur handler */ + onBlur?: () => void; + /** Focus handler */ + onFocus?: () => void; + /** Validation change handler */ + onValidationChange?: (isValid: boolean) => void; + /** Strength change handler */ + onStrengthChange?: (result: PasswordStrengthResult) => void; +} + +/** + * Default password strength calculator + */ +function calculatePasswordStrength(password: string): PasswordStrengthResult { + const feedback: string[] = []; + let score = 0; + + if (!password || password.length === 0) { + return { strength: 'weak', score: 0, feedback: ['Enter a password'] }; + } + + // Length checks + if (password.length >= 8) { + score += 20; + } else { + feedback.push('Use at least 8 characters'); + } + + if (password.length >= 12) { + score += 10; + } + + if (password.length >= 16) { + score += 10; + } + + // Character variety checks + if (/[a-z]/.test(password)) { + score += 10; + } else { + feedback.push('Add lowercase letters'); + } + + if (/[A-Z]/.test(password)) { + score += 15; + } else { + feedback.push('Add uppercase letters'); + } + + if (/[0-9]/.test(password)) { + score += 15; + } else { + feedback.push('Add numbers'); + } + + if (/[^a-zA-Z0-9]/.test(password)) { + score += 20; + } else { + feedback.push('Add special characters'); + } + + // Determine strength level + let strength: PasswordStrength; + if (score < 30) { + strength = 'weak'; + } else if (score < 50) { + strength = 'fair'; + } else if (score < 75) { + strength = 'good'; + } else { + strength = 'strong'; + } + + return { strength, score: Math.min(score, 100), feedback }; +} + +/** + * Eye icon for showing password + */ +function EyeIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Eye-off icon for hiding password + */ +function EyeOffIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Strength meter component + */ +function StrengthMeter({ result }: { result: PasswordStrengthResult }) { + const { strength, score } = result; + + const colorClasses: Record = { + weak: 'bg-red-500', + fair: 'bg-yellow-500', + good: 'bg-blue-500', + strong: 'bg-green-500', + }; + + const labels: Record = { + weak: 'Weak', + fair: 'Fair', + good: 'Good', + strong: 'Strong', + }; + + return ( +
+
+
+
+
+ + {labels[strength]} + +
+
+ ); +} + +/** + * PasswordInput component with visibility toggle and strength meter + */ +export const PasswordInput = forwardRef( + ( + { + name, + label, + placeholder, + defaultValue = '', + required = false, + disabled = false, + readOnly = false, + className = '', + inputClassName = '', + validationRules = [], + validateOn = 'blur', + debounce, + showError = true, + autoDismissError, + maxLength, + autoComplete = 'current-password', + hint, + showToggle = true, + showStrengthMeter = false, + strengthCalculator = calculatePasswordStrength, + onChange, + onBlur, + onFocus, + onValidationChange, + onStrengthChange, + }, + ref, + ) => { + const generatedId = useId(); + const fieldId = `password-${name}-${generatedId}`; + const errorId = `${fieldId}-error`; + const hintId = hint ? `${fieldId}-hint` : undefined; + + // Visibility state + const [isVisible, setIsVisible] = useState(false); + + // Toggle visibility + const toggleVisibility = useCallback(() => { + setIsVisible((prev) => !prev); + }, []); + + // Field state management + const { value, isTouched, handleChange, handleBlur, handleFocus } = useFormField({ + initialValue: defaultValue, + disabled, + readOnly, + onChange: (val) => { + onChange?.(val as string); + if (validateOn === 'change') { + validate(val as string); + } + }, + onBlur: () => { + onBlur?.(); + if (validateOn === 'blur') { + validate(value as string); + } + }, + onFocus, + }); + + // Validation + const { errors, isValid, validate } = useValidation({ + rules: validationRules, + debounce, + }); + + // Password strength + const strengthResult = useMemo(() => { + if (!showStrengthMeter) { + return { strength: 'weak' as PasswordStrength, score: 0, feedback: [] }; + } + return strengthCalculator(value as string); + }, [value, showStrengthMeter, strengthCalculator]); + + // Notify parent of strength changes + useEffect(() => { + if (showStrengthMeter && onStrengthChange) { + onStrengthChange(strengthResult); + } + }, [strengthResult, showStrengthMeter, onStrengthChange]); + + // Notify parent of validation changes + useEffect(() => { + if (onValidationChange) { + onValidationChange(isValid); + } + }, [isValid, onValidationChange]); + + // Error handling + const { error, setErrors } = useFieldError({ + fieldName: name, + autoDismiss: autoDismissError, + }); + + // Sync validation errors to field errors + if (errors.length > 0 && error !== errors[0]) { + setErrors(errors); + } else if (errors.length === 0 && error !== null) { + setErrors([]); + } + + const hasError = isTouched && error !== null; + const showHint = hint && !hasError; + + return ( +
+ {label ? ( + + ) : ( + + )} +
+ + {showToggle && !disabled && ( + + )} +
+ {showStrengthMeter && (value as string).length > 0 && ( + + )} + {showHint && ( +
+ {hint} +
+ )} + {showError && hasError && ( + + )} +
+ ); + }, +); + +PasswordInput.displayName = 'PasswordInput'; + +export { calculatePasswordStrength }; diff --git a/src/components/__tests__/PasswordInput.test.tsx b/src/components/__tests__/PasswordInput.test.tsx new file mode 100644 index 0000000..9555cce --- /dev/null +++ b/src/components/__tests__/PasswordInput.test.tsx @@ -0,0 +1,381 @@ +/** + * Tests for PasswordInput component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + PasswordInput, + calculatePasswordStrength, + type PasswordStrengthResult, +} from '../PasswordInput'; +import { required, minLength } from '../../validation/validators'; + +describe('PasswordInput', () => { + describe('basic rendering', () => { + it('renders input with name', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders with password type by default', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('renders with placeholder', () => { + render(); + const input = screen.getByPlaceholderText('Enter password'); + expect(input).toBeInTheDocument(); + }); + + it('renders with default value', () => { + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + expect(input.value).toBe('secret'); + }); + }); + + describe('label and accessibility', () => { + it('renders with label', () => { + render(); + const label = screen.getByText('Password'); + expect(label).toBeInTheDocument(); + }); + + it('associates label with input', () => { + render(); + const input = screen.getByLabelText('Password'); + expect(input).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + const required = screen.getAllByText('*'); + expect(required.length).toBeGreaterThan(0); + }); + + it('renders with hint text', () => { + render(); + const hint = screen.getByText('Minimum 8 characters'); + expect(hint).toBeInTheDocument(); + }); + + it('has proper sr-only label when no visible label', () => { + render(); + const label = document.querySelector('label.sr-only'); + expect(label).toBeInTheDocument(); + }); + }); + + describe('visibility toggle', () => { + it('renders toggle button by default', () => { + render(); + const toggle = screen.getByRole('button', { name: /show password/i }); + expect(toggle).toBeInTheDocument(); + }); + + it('does not render toggle when showToggle is false', () => { + render(); + const toggle = screen.queryByRole('button', { name: /show password/i }); + expect(toggle).not.toBeInTheDocument(); + }); + + it('does not render toggle when disabled', () => { + render(); + const toggle = screen.queryByRole('button', { name: /show password/i }); + expect(toggle).not.toBeInTheDocument(); + }); + + it('toggles password visibility on click', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="password"]'); + const toggle = screen.getByRole('button', { name: /show password/i }); + + expect(input).toHaveAttribute('type', 'password'); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'text'); + + // Button should now say "Hide password" + const hideToggle = screen.getByRole('button', { name: /hide password/i }); + expect(hideToggle).toBeInTheDocument(); + + await user.click(hideToggle); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('toggle button has no tab focus (tabIndex=-1)', () => { + render(); + const toggle = screen.getByRole('button', { name: /show password/i }); + expect(toggle).toHaveAttribute('tabindex', '-1'); + }); + }); + + describe('user interaction', () => { + it('handles user input', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'secret123'); + expect(input.value).toBe('secret123'); + }); + + it('calls onChange handler', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'test'); + expect(onChange).toHaveBeenCalled(); + }); + + it('calls onBlur handler', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('calls onFocus handler', async () => { + const user = userEvent.setup(); + const onFocus = vi.fn(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.click(input); + expect(onFocus).toHaveBeenCalledTimes(1); + }); + }); + + describe('validation', () => { + it('shows error after validation fails', async () => { + const user = userEvent.setup(); + render( + , + ); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'short'); + await user.tab(); + + const error = await screen.findByRole('alert'); + expect(error).toBeInTheDocument(); + }); + + it('shows required error', async () => { + const user = userEvent.setup(); + render( + , + ); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + + const error = await screen.findByRole('alert'); + expect(error).toBeInTheDocument(); + }); + + it('calls onValidationChange callback', async () => { + const user = userEvent.setup(); + const onValidationChange = vi.fn(); + render( + , + ); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'test'); + expect(onValidationChange).toHaveBeenCalled(); + }); + }); + + describe('password strength meter', () => { + it('does not show strength meter by default', () => { + render(); + const strengthMeter = document.querySelector('.formkit-password-strength'); + expect(strengthMeter).not.toBeInTheDocument(); + }); + + it('shows strength meter when enabled', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'test'); + + const strengthMeter = document.querySelector('.formkit-password-strength'); + expect(strengthMeter).toBeInTheDocument(); + }); + + it('shows strength level text', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'weak'); + expect(screen.getByText(/weak|fair|good|strong/i)).toBeInTheDocument(); + }); + + it('strength meter has progressbar role', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'test'); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + }); + + it('calls onStrengthChange callback', async () => { + const user = userEvent.setup(); + const onStrengthChange = vi.fn(); + render( + , + ); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'StrongPass123!'); + expect(onStrengthChange).toHaveBeenCalled(); + }); + + it('uses custom strength calculator', async () => { + const user = userEvent.setup(); + const customCalculator = vi.fn( + (): PasswordStrengthResult => ({ + strength: 'strong', + score: 100, + feedback: [], + }), + ); + + render( + , + ); + const input = document.querySelector('input[name="password"]') as HTMLInputElement; + + await user.type(input, 'any'); + expect(customCalculator).toHaveBeenCalled(); + expect(screen.getByText('Strong')).toBeInTheDocument(); + }); + }); + + describe('disabled and readonly states', () => { + it('renders disabled input', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toBeDisabled(); + }); + + it('renders readonly input', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toHaveAttribute('readonly'); + }); + }); + + describe('autocomplete attribute', () => { + it('defaults to current-password', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toHaveAttribute('autocomplete', 'current-password'); + }); + + it('accepts new-password for registration forms', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toHaveAttribute('autocomplete', 'new-password'); + }); + + it('accepts off to disable autocomplete', () => { + render(); + const input = document.querySelector('input[name="password"]'); + expect(input).toHaveAttribute('autocomplete', 'off'); + }); + }); +}); + +describe('calculatePasswordStrength', () => { + it('returns weak for empty password', () => { + const result = calculatePasswordStrength(''); + expect(result.strength).toBe('weak'); + expect(result.score).toBe(0); + }); + + it('returns weak for short passwords', () => { + const result = calculatePasswordStrength('abc'); + expect(result.strength).toBe('weak'); + }); + + it('returns fair for medium passwords', () => { + const result = calculatePasswordStrength('password1'); + expect(result.strength).toBe('fair'); + }); + + it('returns good for decent passwords', () => { + const result = calculatePasswordStrength('Password1'); + expect(result.strength).toBe('good'); + }); + + it('returns strong for complex passwords', () => { + const result = calculatePasswordStrength('MyP@ssword123!'); + expect(result.strength).toBe('strong'); + }); + + it('provides feedback for missing lowercase', () => { + const result = calculatePasswordStrength('PASSWORD123!'); + expect(result.feedback).toContain('Add lowercase letters'); + }); + + it('provides feedback for missing uppercase', () => { + const result = calculatePasswordStrength('password123!'); + expect(result.feedback).toContain('Add uppercase letters'); + }); + + it('provides feedback for missing numbers', () => { + const result = calculatePasswordStrength('Password!'); + expect(result.feedback).toContain('Add numbers'); + }); + + it('provides feedback for missing special characters', () => { + const result = calculatePasswordStrength('Password123'); + expect(result.feedback).toContain('Add special characters'); + }); + + it('provides feedback for short length', () => { + const result = calculatePasswordStrength('Pass1!'); + expect(result.feedback).toContain('Use at least 8 characters'); + }); + + it('caps score at 100', () => { + const result = calculatePasswordStrength('VeryLongComplexP@ssword123!@#$%'); + expect(result.score).toBeLessThanOrEqual(100); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index 7cf32db..903618b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,6 @@ export * from './Form'; export * from './Input'; +export * from './PasswordInput'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From 7f1c2e8d35ae36619527dd304ffa4ccbf8d503b2 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 13:53:48 +0000 Subject: [PATCH 07/51] add NumberInput component with increment buttons and formatting --- src/components/NumberInput.tsx | 555 ++++++++++++++++++ src/components/__tests__/NumberInput.test.tsx | 443 ++++++++++++++ src/components/index.ts | 1 + 3 files changed, 999 insertions(+) create mode 100644 src/components/NumberInput.tsx create mode 100644 src/components/__tests__/NumberInput.test.tsx diff --git a/src/components/NumberInput.tsx b/src/components/NumberInput.tsx new file mode 100644 index 0000000..e873733 --- /dev/null +++ b/src/components/NumberInput.tsx @@ -0,0 +1,555 @@ +/** + * NumberInput component with increment/decrement buttons and formatting + */ + +import { forwardRef, useId, useEffect, useState, useCallback, useRef } from 'react'; +import type { ValidationRule } from '../validation/types'; +import { useFormField } from '../hooks/useFormField'; +import { useValidation } from '../hooks/useValidation'; +import { useFieldError } from '../hooks/useFieldError'; + +/** + * Number formatting options + */ +export interface NumberFormatOptions { + /** Decimal separator (default: '.') */ + decimalSeparator?: string; + /** Thousands separator (default: ',') */ + thousandsSeparator?: string; + /** Number of decimal places */ + decimalPlaces?: number; + /** Prefix (e.g., '$') */ + prefix?: string; + /** Suffix (e.g., '%') */ + suffix?: string; +} + +/** + * Props for NumberInput component + */ +export interface NumberInputProps { + /** Input name attribute */ + name: string; + /** Label text */ + label?: string; + /** Placeholder text */ + placeholder?: string; + /** Default value */ + defaultValue?: number; + /** Minimum value */ + min?: number; + /** Maximum value */ + max?: number; + /** Step increment (default: 1) */ + step?: number; + /** Whether field is required */ + required?: boolean; + /** Whether field is disabled */ + disabled?: boolean; + /** Whether field is read-only */ + readOnly?: boolean; + /** Custom CSS class name for the container */ + className?: string; + /** Custom CSS class name for the input element */ + inputClassName?: string; + /** Validation rules */ + validationRules?: ValidationRule[]; + /** When to validate */ + validateOn?: 'change' | 'blur' | 'submit'; + /** Debounce validation (ms) */ + debounce?: number; + /** Show error message */ + showError?: boolean; + /** Auto-dismiss errors after delay (ms) */ + autoDismissError?: number; + /** Hint or help text */ + hint?: string; + /** Show increment/decrement buttons */ + showButtons?: boolean; + /** Button position */ + buttonPosition?: 'sides' | 'right'; + /** Number formatting options */ + format?: NumberFormatOptions; + /** Allow negative numbers */ + allowNegative?: boolean; + /** Allow decimals */ + allowDecimals?: boolean; + /** Change handler */ + onChange?: (value: number | null) => void; + /** Blur handler */ + onBlur?: () => void; + /** Focus handler */ + onFocus?: () => void; + /** Validation change handler */ + onValidationChange?: (isValid: boolean) => void; +} + +/** + * Format a number with the given options + */ +export function formatNumber(value: number | null, options: NumberFormatOptions = {}): string { + if (value === null || isNaN(value)) return ''; + + const { + decimalSeparator = '.', + thousandsSeparator = ',', + decimalPlaces, + prefix = '', + suffix = '', + } = options; + + let numStr: string; + if (decimalPlaces !== undefined) { + numStr = value.toFixed(decimalPlaces); + } else { + numStr = String(value); + } + + // Split into integer and decimal parts + const [intPart, decPart] = numStr.split('.'); + + // Add thousands separator + const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator); + + // Combine with decimal + const formatted = decPart ? `${formattedInt}${decimalSeparator}${decPart}` : formattedInt; + + return `${prefix}${formatted}${suffix}`; +} + +/** + * Parse a formatted string back to a number + */ +export function parseFormattedNumber( + value: string, + options: NumberFormatOptions = {}, +): number | null { + if (!value || value.trim() === '') return null; + + const { decimalSeparator = '.', thousandsSeparator = ',', prefix = '', suffix = '' } = options; + + // Remove prefix and suffix + let cleaned = value; + if (prefix && cleaned.startsWith(prefix)) { + cleaned = cleaned.slice(prefix.length); + } + if (suffix && cleaned.endsWith(suffix)) { + cleaned = cleaned.slice(0, -suffix.length); + } + + // Remove thousands separators + cleaned = cleaned.split(thousandsSeparator).join(''); + + // Replace decimal separator with standard dot + if (decimalSeparator !== '.') { + cleaned = cleaned.replace(decimalSeparator, '.'); + } + + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? null : parsed; +} + +/** + * Clamp a value between min and max + */ +function clamp(value: number, min?: number, max?: number): number { + if (min !== undefined && value < min) return min; + if (max !== undefined && value > max) return max; + return value; +} + +/** + * Plus icon for increment button + */ +function PlusIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Minus icon for decrement button + */ +function MinusIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * NumberInput component with increment/decrement buttons and formatting + */ +export const NumberInput = forwardRef( + ( + { + name, + label, + placeholder, + defaultValue, + min, + max, + step = 1, + required = false, + disabled = false, + readOnly = false, + className = '', + inputClassName = '', + validationRules = [], + validateOn = 'blur', + debounce, + showError = true, + autoDismissError, + hint, + showButtons = true, + buttonPosition = 'right', + format, + allowNegative = true, + allowDecimals = true, + onChange, + onBlur, + onFocus, + onValidationChange, + }, + ref, + ) => { + const generatedId = useId(); + const fieldId = `number-${name}-${generatedId}`; + const errorId = `${fieldId}-error`; + const hintId = hint ? `${fieldId}-hint` : undefined; + + // Internal ref for input + const inputRef = useRef(null); + + // Store numeric value + const [numericValue, setNumericValue] = useState(defaultValue ?? null); + + // Store display value (formatted string) + const [displayValue, setDisplayValue] = useState(() => + defaultValue !== undefined && format + ? formatNumber(defaultValue, format) + : String(defaultValue ?? ''), + ); + + // Field state management + const { + isTouched, + handleBlur: fieldBlur, + handleFocus, + } = useFormField({ + initialValue: displayValue, + disabled, + readOnly, + onBlur: () => { + onBlur?.(); + // Format on blur + if (format && numericValue !== null) { + setDisplayValue(formatNumber(numericValue, format)); + } + if (validateOn === 'blur') { + validate(String(numericValue ?? '')); + } + }, + onFocus, + }); + + // Validation + const { errors, isValid, validate } = useValidation({ + rules: validationRules, + debounce, + }); + + // Notify parent of validation changes + useEffect(() => { + if (onValidationChange) { + onValidationChange(isValid); + } + }, [isValid, onValidationChange]); + + // Error handling + const { error, setErrors } = useFieldError({ + fieldName: name, + autoDismiss: autoDismissError, + }); + + // Sync validation errors to field errors + if (errors.length > 0 && error !== errors[0]) { + setErrors(errors); + } else if (errors.length === 0 && error !== null) { + setErrors([]); + } + + // Handle input change + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const rawValue = e.target.value; + setDisplayValue(rawValue); + + // Parse the value + const parsed = format ? parseFormattedNumber(rawValue, format) : parseFloat(rawValue); + + // Validate characters + if (rawValue !== '') { + // Allow negative sign at start + const negativeOk = allowNegative || !rawValue.startsWith('-'); + // Allow decimal point if decimals allowed + const decimalOk = allowDecimals || !rawValue.includes('.'); + + if (!negativeOk || !decimalOk) { + return; + } + } + + if (parsed !== null && !isNaN(parsed)) { + setNumericValue(parsed); + onChange?.(parsed); + if (validateOn === 'change') { + validate(String(parsed)); + } + } else if (rawValue === '' || rawValue === '-') { + setNumericValue(null); + onChange?.(null); + if (validateOn === 'change') { + validate(''); + } + } + }, + [format, allowNegative, allowDecimals, onChange, validateOn, validate], + ); + + // Handle blur to format and clamp + const handleBlur = useCallback(() => { + if (numericValue !== null) { + const clamped = clamp(numericValue, min, max); + if (clamped !== numericValue) { + setNumericValue(clamped); + onChange?.(clamped); + } + if (format) { + setDisplayValue(formatNumber(clamped, format)); + } else { + setDisplayValue(String(clamped)); + } + } + fieldBlur(); + }, [numericValue, min, max, format, onChange, fieldBlur]); + + // Increment value + const increment = useCallback(() => { + if (disabled || readOnly) return; + const current = numericValue ?? min ?? 0; + const newValue = clamp(current + step, min, max); + setNumericValue(newValue); + setDisplayValue(format ? formatNumber(newValue, format) : String(newValue)); + onChange?.(newValue); + if (validateOn === 'change') { + validate(String(newValue)); + } + }, [numericValue, min, max, step, disabled, readOnly, format, onChange, validateOn, validate]); + + // Decrement value + const decrement = useCallback(() => { + if (disabled || readOnly) return; + const current = numericValue ?? max ?? 0; + const newValue = clamp(current - step, min, max); + setNumericValue(newValue); + setDisplayValue(format ? formatNumber(newValue, format) : String(newValue)); + onChange?.(newValue); + if (validateOn === 'change') { + validate(String(newValue)); + } + }, [numericValue, min, max, step, disabled, readOnly, format, onChange, validateOn, validate]); + + // Handle keyboard events + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + increment(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + decrement(); + } + }, + [increment, decrement], + ); + + const hasError = isTouched && error !== null; + const showHint = hint && !hasError; + + // Button styles + const buttonBaseClass = + 'flex items-center justify-center h-full px-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'; + + // Input padding based on button position + const inputPaddingClass = + showButtons && buttonPosition === 'sides' + ? 'px-10' + : showButtons && buttonPosition === 'right' + ? 'pr-16' + : ''; + + return ( +
+ {label ? ( + + ) : ( + + )} +
+ {showButtons && buttonPosition === 'sides' && ( + + )} + { + // Handle both refs + (inputRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + id={fieldId} + name={name} + type="text" + inputMode="decimal" + value={displayValue} + onChange={handleChange} + onBlur={handleBlur} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + placeholder={placeholder} + required={required} + disabled={disabled} + readOnly={readOnly} + className={`formkit-number-input ${inputClassName} ${inputPaddingClass} w-full px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:border-gray-300 ${hasError ? 'formkit-number-error border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400' : ''}`} + aria-invalid={hasError} + aria-describedby={ + [hasError ? errorId : undefined, showHint ? hintId : undefined] + .filter(Boolean) + .join(' ') || undefined + } + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={numericValue ?? undefined} + /> + {showButtons && buttonPosition === 'sides' && ( + + )} + {showButtons && buttonPosition === 'right' && ( +
+ + +
+ )} +
+ {showHint && ( +
+ {hint} +
+ )} + {showError && hasError && ( + + )} +
+ ); + }, +); + +NumberInput.displayName = 'NumberInput'; diff --git a/src/components/__tests__/NumberInput.test.tsx b/src/components/__tests__/NumberInput.test.tsx new file mode 100644 index 0000000..cbc88f4 --- /dev/null +++ b/src/components/__tests__/NumberInput.test.tsx @@ -0,0 +1,443 @@ +/** + * Tests for NumberInput component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NumberInput, formatNumber, parseFormattedNumber } from '../NumberInput'; +import { required } from '../../validation/validators'; + +describe('NumberInput', () => { + describe('basic rendering', () => { + it('renders input with name', () => { + render(); + const input = document.querySelector('input[name="quantity"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders with text type and decimal inputMode', () => { + render(); + const input = document.querySelector('input[name="quantity"]'); + expect(input).toHaveAttribute('type', 'text'); + expect(input).toHaveAttribute('inputMode', 'decimal'); + }); + + it('renders with placeholder', () => { + render(); + const input = screen.getByPlaceholderText('Enter amount'); + expect(input).toBeInTheDocument(); + }); + + it('renders with default value', () => { + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + expect(input.value).toBe('42'); + }); + }); + + describe('label and accessibility', () => { + it('renders with label', () => { + render(); + const label = screen.getByText('Quantity'); + expect(label).toBeInTheDocument(); + }); + + it('associates label with input', () => { + render(); + const input = screen.getByLabelText('Quantity'); + expect(input).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + const required = screen.getAllByText('*'); + expect(required.length).toBeGreaterThan(0); + }); + + it('renders with hint text', () => { + render(); + const hint = screen.getByText('Enter a number between 1-100'); + expect(hint).toBeInTheDocument(); + }); + + it('has proper sr-only label when no visible label', () => { + render(); + const label = document.querySelector('label.sr-only'); + expect(label).toBeInTheDocument(); + }); + + it('sets aria-valuemin and aria-valuemax', () => { + render(); + const input = document.querySelector('input[name="quantity"]'); + expect(input).toHaveAttribute('aria-valuemin', '0'); + expect(input).toHaveAttribute('aria-valuemax', '100'); + }); + }); + + describe('increment/decrement buttons', () => { + it('renders increment and decrement buttons by default', () => { + render(); + const increaseBtn = screen.getByRole('button', { name: /increase/i }); + const decreaseBtn = screen.getByRole('button', { name: /decrease/i }); + expect(increaseBtn).toBeInTheDocument(); + expect(decreaseBtn).toBeInTheDocument(); + }); + + it('does not render buttons when showButtons is false', () => { + render(); + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); + }); + + it('increments value on button click', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + const increaseBtn = screen.getByRole('button', { name: /increase/i }); + + await user.click(increaseBtn); + expect(input.value).toBe('6'); + }); + + it('decrements value on button click', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + const decreaseBtn = screen.getByRole('button', { name: /decrease/i }); + + await user.click(decreaseBtn); + expect(input.value).toBe('4'); + }); + + it('respects step value', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + const increaseBtn = screen.getByRole('button', { name: /increase/i }); + + await user.click(increaseBtn); + expect(input.value).toBe('15'); + }); + + it('disables increment at max', async () => { + render(); + const increaseBtn = screen.getByRole('button', { name: /increase/i }); + expect(increaseBtn).toBeDisabled(); + }); + + it('disables decrement at min', async () => { + render(); + const decreaseBtn = screen.getByRole('button', { name: /decrease/i }); + expect(decreaseBtn).toBeDisabled(); + }); + + it('buttons have no tab focus (tabIndex=-1)', () => { + render(); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('tabindex', '-1'); + }); + }); + }); + + describe('keyboard navigation', () => { + it('increments on ArrowUp', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.click(input); + await user.keyboard('{ArrowUp}'); + + expect(input.value).toBe('6'); + }); + + it('decrements on ArrowDown', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.click(input); + await user.keyboard('{ArrowDown}'); + + expect(input.value).toBe('4'); + }); + }); + + describe('min/max constraints', () => { + it('clamps value to max on blur', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.type(input, '15'); + await user.tab(); + + expect(input.value).toBe('10'); + }); + + it('clamps value to min on blur', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.type(input, '2'); + await user.tab(); + + expect(input.value).toBe('5'); + }); + }); + + describe('user interaction', () => { + it('handles user input', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.type(input, '123'); + expect(input.value).toBe('123'); + }); + + it('calls onChange handler with number', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.type(input, '42'); + expect(onChange).toHaveBeenCalledWith(42); + }); + + it('calls onBlur handler', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('calls onFocus handler', async () => { + const user = userEvent.setup(); + const onFocus = vi.fn(); + render(); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.click(input); + expect(onFocus).toHaveBeenCalledTimes(1); + }); + }); + + describe('validation', () => { + it('shows required error', async () => { + const user = userEvent.setup(); + render( + , + ); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + + const error = await screen.findByRole('alert'); + expect(error).toBeInTheDocument(); + }); + + it('calls onValidationChange callback', async () => { + const user = userEvent.setup(); + const onValidationChange = vi.fn(); + render( + , + ); + const input = document.querySelector('input[name="quantity"]') as HTMLInputElement; + + await user.type(input, '5'); + expect(onValidationChange).toHaveBeenCalled(); + }); + }); + + describe('number formatting', () => { + it('formats number with thousands separator', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="amount"]') as HTMLInputElement; + + await user.type(input, '1000000'); + await user.tab(); + + expect(input.value).toBe('1,000,000'); + }); + + it('formats with prefix', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="price"]') as HTMLInputElement; + + await user.type(input, '1000'); + await user.tab(); + + expect(input.value).toBe('$1,000'); + }); + + it('formats with suffix', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="percent"]') as HTMLInputElement; + + await user.type(input, '75'); + await user.tab(); + + expect(input.value).toBe('75%'); + }); + + it('formats with decimal places', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="price"]') as HTMLInputElement; + + await user.type(input, '10'); + await user.tab(); + + expect(input.value).toBe('10.00'); + }); + + it('displays formatted default value', () => { + render( + , + ); + const input = document.querySelector('input[name="amount"]') as HTMLInputElement; + expect(input.value).toBe('$1,234,567'); + }); + }); + + describe('disabled and readonly states', () => { + it('renders disabled input', () => { + render(); + const input = document.querySelector('input[name="quantity"]'); + expect(input).toBeDisabled(); + }); + + it('renders readonly input', () => { + render(); + const input = document.querySelector('input[name="quantity"]'); + expect(input).toHaveAttribute('readonly'); + }); + + it('does not increment/decrement when disabled', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const increaseBtn = screen.getByRole('button', { name: /increase/i }); + await user.click(increaseBtn); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('button positions', () => { + it('renders buttons on right by default', () => { + render(); + const container = document.querySelector('.formkit-number-container'); + expect(container).toBeInTheDocument(); + // Both buttons should be present + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(2); + }); + + it('renders buttons on sides when buttonPosition is sides', () => { + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(2); + }); + }); +}); + +describe('formatNumber', () => { + it('formats with thousands separator', () => { + expect(formatNumber(1234567)).toBe('1,234,567'); + }); + + it('formats with decimal places', () => { + expect(formatNumber(123.4, { decimalPlaces: 2 })).toBe('123.40'); + }); + + it('formats with prefix', () => { + expect(formatNumber(100, { prefix: '$' })).toBe('$100'); + }); + + it('formats with suffix', () => { + expect(formatNumber(50, { suffix: '%' })).toBe('50%'); + }); + + it('formats with custom separators', () => { + expect( + formatNumber(1234567.89, { + thousandsSeparator: ' ', + decimalSeparator: ',', + decimalPlaces: 2, + }), + ).toBe('1 234 567,89'); + }); + + it('returns empty string for null', () => { + expect(formatNumber(null)).toBe(''); + }); + + it('returns empty string for NaN', () => { + expect(formatNumber(NaN)).toBe(''); + }); +}); + +describe('parseFormattedNumber', () => { + it('parses simple number', () => { + expect(parseFormattedNumber('123')).toBe(123); + }); + + it('parses number with thousands separator', () => { + expect(parseFormattedNumber('1,234,567')).toBe(1234567); + }); + + it('parses number with prefix', () => { + expect(parseFormattedNumber('$1,000', { prefix: '$' })).toBe(1000); + }); + + it('parses number with suffix', () => { + expect(parseFormattedNumber('50%', { suffix: '%' })).toBe(50); + }); + + it('parses number with custom separators', () => { + expect( + parseFormattedNumber('1 234,56', { + thousandsSeparator: ' ', + decimalSeparator: ',', + }), + ).toBe(1234.56); + }); + + it('returns null for empty string', () => { + expect(parseFormattedNumber('')).toBeNull(); + }); + + it('returns null for invalid input', () => { + expect(parseFormattedNumber('abc')).toBeNull(); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index 903618b..e1c4661 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ export * from './Form'; export * from './Input'; export * from './PasswordInput'; +export * from './NumberInput'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From a8b6ed9d9c83061b9f9289239c5036c04e838499 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 14:09:15 +0000 Subject: [PATCH 08/51] add DatePicker component with calendar popup --- src/components/DatePicker.tsx | 735 +++++++++++++++++++ src/components/__tests__/DatePicker.test.tsx | 504 +++++++++++++ src/components/index.ts | 1 + 3 files changed, 1240 insertions(+) create mode 100644 src/components/DatePicker.tsx create mode 100644 src/components/__tests__/DatePicker.test.tsx diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx new file mode 100644 index 0000000..c60c7e9 --- /dev/null +++ b/src/components/DatePicker.tsx @@ -0,0 +1,735 @@ +/** + * DatePicker component with calendar popup + */ + +import { forwardRef, useId, useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import type { ValidationRule } from '../validation/types'; +import { useFormField } from '../hooks/useFormField'; +import { useValidation } from '../hooks/useValidation'; +import { useFieldError } from '../hooks/useFieldError'; + +/** + * Date format options + */ +export type DateFormat = 'yyyy-MM-dd' | 'MM/dd/yyyy' | 'dd/MM/yyyy' | 'dd.MM.yyyy'; + +/** + * Props for DatePicker component + */ +export interface DatePickerProps { + /** Input name attribute */ + name: string; + /** Label text */ + label?: string; + /** Placeholder text */ + placeholder?: string; + /** Default value (Date or ISO string) */ + defaultValue?: Date | string; + /** Minimum selectable date */ + minDate?: Date; + /** Maximum selectable date */ + maxDate?: Date; + /** Whether field is required */ + required?: boolean; + /** Whether field is disabled */ + disabled?: boolean; + /** Whether field is read-only */ + readOnly?: boolean; + /** Custom CSS class name for the container */ + className?: string; + /** Custom CSS class name for the input element */ + inputClassName?: string; + /** Validation rules */ + validationRules?: ValidationRule[]; + /** When to validate */ + validateOn?: 'change' | 'blur' | 'submit'; + /** Debounce validation (ms) */ + debounce?: number; + /** Show error message */ + showError?: boolean; + /** Auto-dismiss errors after delay (ms) */ + autoDismissError?: number; + /** Hint or help text */ + hint?: string; + /** Date display format */ + dateFormat?: DateFormat; + /** First day of the week (0 = Sunday, 1 = Monday) */ + firstDayOfWeek?: 0 | 1; + /** Show today button */ + showTodayButton?: boolean; + /** Show clear button */ + showClearButton?: boolean; + /** Change handler */ + onChange?: (value: Date | null) => void; + /** Blur handler */ + onBlur?: () => void; + /** Focus handler */ + onFocus?: () => void; + /** Validation change handler */ + onValidationChange?: (isValid: boolean) => void; +} + +/** + * Format a date to a string + */ +export function formatDate(date: Date | null, format: DateFormat = 'yyyy-MM-dd'): string { + if (!date || isNaN(date.getTime())) return ''; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + switch (format) { + case 'yyyy-MM-dd': + return `${year}-${month}-${day}`; + case 'MM/dd/yyyy': + return `${month}/${day}/${year}`; + case 'dd/MM/yyyy': + return `${day}/${month}/${year}`; + case 'dd.MM.yyyy': + return `${day}.${month}.${year}`; + default: + return `${year}-${month}-${day}`; + } +} + +/** + * Parse a date string to a Date object + */ +export function parseDate(value: string, format: DateFormat = 'yyyy-MM-dd'): Date | null { + if (!value || value.trim() === '') return null; + + let year: number, month: number, day: number; + + try { + switch (format) { + case 'yyyy-MM-dd': { + const [y, m, d] = value.split('-').map(Number); + year = y; + month = m; + day = d; + break; + } + case 'MM/dd/yyyy': { + const [m, d, y] = value.split('/').map(Number); + year = y; + month = m; + day = d; + break; + } + case 'dd/MM/yyyy': { + const [d, m, y] = value.split('/').map(Number); + year = y; + month = m; + day = d; + break; + } + case 'dd.MM.yyyy': { + const [d, m, y] = value.split('.').map(Number); + year = y; + month = m; + day = d; + break; + } + default: + return null; + } + + if (isNaN(year) || isNaN(month) || isNaN(day)) return null; + + const date = new Date(year, month - 1, day); + if (isNaN(date.getTime())) return null; + + return date; + } catch { + return null; + } +} + +/** + * Check if two dates are the same day + */ +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +/** + * Check if a date is within a range + */ +function isDateInRange(date: Date, minDate?: Date, maxDate?: Date): boolean { + if (minDate && date < minDate) return false; + if (maxDate && date > maxDate) return false; + return true; +} + +/** + * Get days in a month + */ +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +/** + * Get day of week for first day of month (0-6) + */ +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +/** + * Month names + */ +const MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +/** + * Short day names + */ +const DAY_NAMES_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +/** + * Calendar icon + */ +function CalendarIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Chevron left icon + */ +function ChevronLeftIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Chevron right icon + */ +function ChevronRightIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Calendar component + */ +interface CalendarProps { + selectedDate: Date | null; + viewDate: Date; + minDate?: Date; + maxDate?: Date; + firstDayOfWeek: 0 | 1; + showTodayButton: boolean; + showClearButton: boolean; + onSelectDate: (date: Date) => void; + onClear: () => void; + onNavigate: (date: Date) => void; +} + +function Calendar({ + selectedDate, + viewDate, + minDate, + maxDate, + firstDayOfWeek, + showTodayButton, + showClearButton, + onSelectDate, + onClear, + onNavigate, +}: CalendarProps) { + const today = useMemo(() => new Date(), []); + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + // Adjust for first day of week + const adjustedFirstDay = (firstDay - firstDayOfWeek + 7) % 7; + + // Day names adjusted for first day of week + const dayNames = useMemo(() => { + const names = [...DAY_NAMES_SHORT]; + if (firstDayOfWeek === 1) { + names.push(names.shift()!); + } + return names; + }, [firstDayOfWeek]); + + // Navigate to previous month + const prevMonth = useCallback(() => { + const newDate = new Date(year, month - 1, 1); + onNavigate(newDate); + }, [year, month, onNavigate]); + + // Navigate to next month + const nextMonth = useCallback(() => { + const newDate = new Date(year, month + 1, 1); + onNavigate(newDate); + }, [year, month, onNavigate]); + + // Select today + const selectToday = useCallback(() => { + onSelectDate(today); + }, [today, onSelectDate]); + + // Generate calendar grid + const days = useMemo(() => { + const result: (number | null)[] = []; + + // Add empty cells for days before first day of month + for (let i = 0; i < adjustedFirstDay; i++) { + result.push(null); + } + + // Add days of month + for (let day = 1; day <= daysInMonth; day++) { + result.push(day); + } + + return result; + }, [adjustedFirstDay, daysInMonth]); + + return ( +
+ {/* Header */} +
+ + + {MONTH_NAMES[month]} {year} + + +
+ + {/* Day names */} +
+ {dayNames.map((name) => ( +
+ {name} +
+ ))} +
+ + {/* Days grid */} +
+ {days.map((day, index) => { + if (day === null) { + return
; + } + + const date = new Date(year, month, day); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isToday = isSameDay(date, today); + const isDisabled = !isDateInRange(date, minDate, maxDate); + + return ( + + ); + })} +
+ + {/* Footer */} + {(showTodayButton || showClearButton) && ( +
+ {showTodayButton && ( + + )} + {showClearButton && ( + + )} +
+ )} +
+ ); +} + +/** + * DatePicker component with calendar popup + */ +export const DatePicker = forwardRef( + ( + { + name, + label, + placeholder = 'Select date', + defaultValue, + minDate, + maxDate, + required = false, + disabled = false, + readOnly = false, + className = '', + inputClassName = '', + validationRules = [], + validateOn = 'blur', + debounce, + showError = true, + autoDismissError, + hint, + dateFormat = 'yyyy-MM-dd', + firstDayOfWeek = 0, + showTodayButton = true, + showClearButton = true, + onChange, + onBlur, + onFocus, + onValidationChange, + }, + ref, + ) => { + const generatedId = useId(); + const fieldId = `date-${name}-${generatedId}`; + const errorId = `${fieldId}-error`; + const hintId = hint ? `${fieldId}-hint` : undefined; + + // Internal refs + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Parse default value + const initialDate = useMemo(() => { + if (!defaultValue) return null; + if (defaultValue instanceof Date) return defaultValue; + return parseDate(defaultValue, dateFormat) || new Date(defaultValue); + }, [defaultValue, dateFormat]); + + // State + const [selectedDate, setSelectedDate] = useState(initialDate); + const [isOpen, setIsOpen] = useState(false); + const [viewDate, setViewDate] = useState(initialDate || new Date()); + + // Display value + const displayValue = selectedDate ? formatDate(selectedDate, dateFormat) : ''; + + // Field state management + const { + isTouched, + handleBlur: fieldBlur, + handleFocus, + } = useFormField({ + initialValue: displayValue, + disabled, + readOnly, + onBlur: () => { + onBlur?.(); + if (validateOn === 'blur') { + validate(displayValue); + } + }, + onFocus, + }); + + // Validation + const { errors, isValid, validate } = useValidation({ + rules: validationRules, + debounce, + }); + + // Notify parent of validation changes + useEffect(() => { + if (onValidationChange) { + onValidationChange(isValid); + } + }, [isValid, onValidationChange]); + + // Error handling + const { error, setErrors } = useFieldError({ + fieldName: name, + autoDismiss: autoDismissError, + }); + + // Sync validation errors to field errors + if (errors.length > 0 && error !== errors[0]) { + setErrors(errors); + } else if (errors.length === 0 && error !== null) { + setErrors([]); + } + + // Handle date selection + const handleSelectDate = useCallback( + (date: Date) => { + setSelectedDate(date); + setViewDate(date); + setIsOpen(false); + onChange?.(date); + if (validateOn === 'change') { + validate(formatDate(date, dateFormat)); + } + }, + [onChange, validateOn, validate, dateFormat], + ); + + // Handle clear + const handleClear = useCallback(() => { + setSelectedDate(null); + setIsOpen(false); + onChange?.(null); + if (validateOn === 'change') { + validate(''); + } + }, [onChange, validateOn, validate]); + + // Handle navigation + const handleNavigate = useCallback((date: Date) => { + setViewDate(date); + }, []); + + // Toggle calendar + const toggleCalendar = useCallback(() => { + if (!disabled && !readOnly) { + setIsOpen((prev) => !prev); + } + }, [disabled, readOnly]); + + // Handle input change (manual typing) + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + const parsed = parseDate(value, dateFormat); + if (parsed && isDateInRange(parsed, minDate, maxDate)) { + setSelectedDate(parsed); + setViewDate(parsed); + onChange?.(parsed); + } else if (value === '') { + setSelectedDate(null); + onChange?.(null); + } + }, + [dateFormat, minDate, maxDate, onChange], + ); + + // Handle blur + const handleBlur = useCallback(() => { + fieldBlur(); + }, [fieldBlur]); + + // Close on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !isOpen) { + e.preventDefault(); + setIsOpen(true); + } + }, + [isOpen], + ); + + const hasError = isTouched && error !== null; + const showHint = hint && !hasError; + + return ( +
+ {label ? ( + + ) : ( + + )} +
+ { + (inputRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + id={fieldId} + name={name} + type="text" + value={displayValue} + onChange={handleInputChange} + onBlur={handleBlur} + onFocus={handleFocus} + onClick={toggleCalendar} + placeholder={placeholder} + required={required} + disabled={disabled} + readOnly={readOnly} + className={`formkit-datepicker-input ${inputClassName} w-full pl-3 pr-10 py-2 sm:py-2.5 text-sm sm:text-base border border-gray-300 rounded transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:border-gray-300 cursor-pointer ${hasError ? 'formkit-datepicker-error border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400' : ''}`} + aria-invalid={hasError} + aria-describedby={ + [hasError ? errorId : undefined, showHint ? hintId : undefined] + .filter(Boolean) + .join(' ') || undefined + } + aria-expanded={isOpen} + aria-haspopup="dialog" + /> + +
+ + {/* Calendar popup */} + {isOpen && ( +
+ +
+ )} + + {showHint && ( +
+ {hint} +
+ )} + {showError && hasError && ( + + )} +
+ ); + }, +); + +DatePicker.displayName = 'DatePicker'; diff --git a/src/components/__tests__/DatePicker.test.tsx b/src/components/__tests__/DatePicker.test.tsx new file mode 100644 index 0000000..64b8dda --- /dev/null +++ b/src/components/__tests__/DatePicker.test.tsx @@ -0,0 +1,504 @@ +/** + * Tests for DatePicker component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DatePicker, formatDate, parseDate } from '../DatePicker'; +import { required } from '../../validation/validators'; + +describe('DatePicker', () => { + describe('basic rendering', () => { + it('renders input with name', () => { + render(); + const input = document.querySelector('input[name="birthdate"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders with placeholder', () => { + render(); + const input = screen.getByPlaceholderText('Choose a date'); + expect(input).toBeInTheDocument(); + }); + + it('renders with default value', () => { + const date = new Date(2025, 5, 15); // June 15, 2025 + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + expect(input.value).toBe('2025-06-15'); + }); + + it('renders with string default value', () => { + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + expect(input.value).toBe('2025-06-15'); + }); + }); + + describe('label and accessibility', () => { + it('renders with label', () => { + render(); + const label = screen.getByText('Date of Birth'); + expect(label).toBeInTheDocument(); + }); + + it('associates label with input', () => { + render(); + const input = screen.getByLabelText('Date of Birth'); + expect(input).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + const required = screen.getAllByText('*'); + expect(required.length).toBeGreaterThan(0); + }); + + it('renders with hint text', () => { + render(); + const hint = screen.getByText('Select your birthday'); + expect(hint).toBeInTheDocument(); + }); + + it('has proper sr-only label when no visible label', () => { + render(); + const label = document.querySelector('label.sr-only'); + expect(label).toBeInTheDocument(); + }); + + it('has aria-haspopup attribute', () => { + render(); + const input = document.querySelector('input[name="date"]'); + expect(input).toHaveAttribute('aria-haspopup', 'dialog'); + }); + }); + + describe('calendar popup', () => { + it('opens calendar on input click', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const calendar = document.querySelector('.formkit-calendar'); + expect(calendar).toBeInTheDocument(); + }); + + it('opens calendar on calendar icon click', async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole('button', { name: /open calendar/i }); + + await user.click(button); + + const calendar = document.querySelector('.formkit-calendar'); + expect(calendar).toBeInTheDocument(); + }); + + it('closes calendar on date selection', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + // Find and click a day button + const dayButtons = screen.getAllByRole('gridcell'); + const enabledDay = dayButtons.find((btn) => !btn.hasAttribute('disabled')); + if (enabledDay) { + await user.click(enabledDay); + } + + await waitFor(() => { + const calendar = document.querySelector('.formkit-calendar'); + expect(calendar).not.toBeInTheDocument(); + }); + }); + + it('closes calendar on escape key', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + expect(document.querySelector('.formkit-calendar')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(document.querySelector('.formkit-calendar')).not.toBeInTheDocument(); + }); + }); + + it('shows today button', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const todayButton = screen.getByRole('button', { name: /today/i }); + expect(todayButton).toBeInTheDocument(); + }); + + it('shows clear button', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const clearButton = screen.getByRole('button', { name: /clear/i }); + expect(clearButton).toBeInTheDocument(); + }); + + it('selects today when clicking today button', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + const todayButton = screen.getByRole('button', { name: /today/i }); + await user.click(todayButton); + + const today = new Date(); + const expected = formatDate(today); + expect(input.value).toBe(expected); + }); + + it('clears value when clicking clear button', async () => { + const user = userEvent.setup(); + const date = new Date(2025, 5, 15); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + expect(input.value).toBe('2025-06-15'); + + await user.click(input); + const clearButton = screen.getByRole('button', { name: /clear/i }); + await user.click(clearButton); + + expect(input.value).toBe(''); + }); + }); + + describe('month navigation', () => { + it('shows current month by default', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const today = new Date(); + const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + const expectedMonth = monthNames[today.getMonth()]; + + expect(screen.getByText(new RegExp(expectedMonth))).toBeInTheDocument(); + }); + + it('navigates to previous month', async () => { + const user = userEvent.setup(); + const date = new Date(2025, 5, 15); // June 2025 + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + expect(screen.getByText(/June/)).toBeInTheDocument(); + + const prevButton = screen.getByRole('button', { name: /previous month/i }); + await user.click(prevButton); + + expect(screen.getByText(/May/)).toBeInTheDocument(); + }); + + it('navigates to next month', async () => { + const user = userEvent.setup(); + const date = new Date(2025, 5, 15); // June 2025 + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + expect(screen.getByText(/June/)).toBeInTheDocument(); + + const nextButton = screen.getByRole('button', { name: /next month/i }); + await user.click(nextButton); + + expect(screen.getByText(/July/)).toBeInTheDocument(); + }); + }); + + describe('date constraints', () => { + it('disables dates before minDate', async () => { + const user = userEvent.setup(); + const minDate = new Date(2025, 5, 15); // June 15, 2025 + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + // Day 10 should be disabled + const dayButtons = screen.getAllByRole('gridcell'); + const day10 = dayButtons.find((btn) => btn.textContent === '10'); + expect(day10).toBeDisabled(); + }); + + it('disables dates after maxDate', async () => { + const user = userEvent.setup(); + const maxDate = new Date(2025, 5, 15); // June 15, 2025 + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + // Day 20 should be disabled + const dayButtons = screen.getAllByRole('gridcell'); + const day20 = dayButtons.find((btn) => btn.textContent === '20'); + expect(day20).toBeDisabled(); + }); + }); + + describe('date formats', () => { + it('formats date as yyyy-MM-dd by default', () => { + const date = new Date(2025, 5, 15); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + expect(input.value).toBe('2025-06-15'); + }); + + it('formats date as MM/dd/yyyy', () => { + const date = new Date(2025, 5, 15); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + expect(input.value).toBe('06/15/2025'); + }); + + it('formats date as dd/MM/yyyy', () => { + const date = new Date(2025, 5, 15); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + expect(input.value).toBe('15/06/2025'); + }); + + it('formats date as dd.MM.yyyy', () => { + const date = new Date(2025, 5, 15); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + expect(input.value).toBe('15.06.2025'); + }); + }); + + describe('first day of week', () => { + it('starts week on Sunday by default', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const dayHeaders = document.querySelectorAll('.formkit-calendar .grid-cols-7 > div'); + expect(dayHeaders[0].textContent).toBe('Su'); + }); + + it('starts week on Monday when configured', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const dayHeaders = document.querySelectorAll('.formkit-calendar .grid-cols-7 > div'); + expect(dayHeaders[0].textContent).toBe('Mo'); + }); + }); + + describe('user interaction', () => { + it('calls onChange handler on date select', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + + const dayButtons = screen.getAllByRole('gridcell'); + const enabledDay = dayButtons.find((btn) => !btn.hasAttribute('disabled')); + if (enabledDay) { + await user.click(enabledDay); + } + + expect(onChange).toHaveBeenCalled(); + }); + + it('calls onBlur handler', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('calls onFocus handler', async () => { + const user = userEvent.setup(); + const onFocus = vi.fn(); + render(); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + expect(onFocus).toHaveBeenCalled(); + }); + }); + + describe('validation', () => { + it('shows required error', async () => { + const user = userEvent.setup(); + render( + , + ); + const input = document.querySelector('input[name="date"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + + const error = await screen.findByRole('alert'); + expect(error).toBeInTheDocument(); + }); + + it('calls onValidationChange callback', async () => { + const onValidationChange = vi.fn(); + const date = new Date(2025, 5, 15); + render( + , + ); + + await waitFor(() => { + expect(onValidationChange).toHaveBeenCalled(); + }); + }); + }); + + describe('disabled and readonly states', () => { + it('renders disabled input', () => { + render(); + const input = document.querySelector('input[name="date"]'); + expect(input).toBeDisabled(); + }); + + it('renders readonly input', () => { + render(); + const input = document.querySelector('input[name="date"]'); + expect(input).toHaveAttribute('readonly'); + }); + + it('does not open calendar when disabled', async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole('button', { name: /open calendar/i }); + + await user.click(button); + + const calendar = document.querySelector('.formkit-calendar'); + expect(calendar).not.toBeInTheDocument(); + }); + }); +}); + +describe('formatDate', () => { + it('formats as yyyy-MM-dd', () => { + const date = new Date(2025, 5, 15); + expect(formatDate(date, 'yyyy-MM-dd')).toBe('2025-06-15'); + }); + + it('formats as MM/dd/yyyy', () => { + const date = new Date(2025, 5, 15); + expect(formatDate(date, 'MM/dd/yyyy')).toBe('06/15/2025'); + }); + + it('formats as dd/MM/yyyy', () => { + const date = new Date(2025, 5, 15); + expect(formatDate(date, 'dd/MM/yyyy')).toBe('15/06/2025'); + }); + + it('formats as dd.MM.yyyy', () => { + const date = new Date(2025, 5, 15); + expect(formatDate(date, 'dd.MM.yyyy')).toBe('15.06.2025'); + }); + + it('returns empty string for null', () => { + expect(formatDate(null)).toBe(''); + }); + + it('pads single digit month and day', () => { + const date = new Date(2025, 0, 5); // Jan 5 + expect(formatDate(date, 'yyyy-MM-dd')).toBe('2025-01-05'); + }); +}); + +describe('parseDate', () => { + it('parses yyyy-MM-dd format', () => { + const result = parseDate('2025-06-15', 'yyyy-MM-dd'); + expect(result?.getFullYear()).toBe(2025); + expect(result?.getMonth()).toBe(5); + expect(result?.getDate()).toBe(15); + }); + + it('parses MM/dd/yyyy format', () => { + const result = parseDate('06/15/2025', 'MM/dd/yyyy'); + expect(result?.getFullYear()).toBe(2025); + expect(result?.getMonth()).toBe(5); + expect(result?.getDate()).toBe(15); + }); + + it('parses dd/MM/yyyy format', () => { + const result = parseDate('15/06/2025', 'dd/MM/yyyy'); + expect(result?.getFullYear()).toBe(2025); + expect(result?.getMonth()).toBe(5); + expect(result?.getDate()).toBe(15); + }); + + it('parses dd.MM.yyyy format', () => { + const result = parseDate('15.06.2025', 'dd.MM.yyyy'); + expect(result?.getFullYear()).toBe(2025); + expect(result?.getMonth()).toBe(5); + expect(result?.getDate()).toBe(15); + }); + + it('returns null for empty string', () => { + expect(parseDate('')).toBeNull(); + }); + + it('returns null for invalid input', () => { + expect(parseDate('not-a-date')).toBeNull(); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index e1c4661..ad154a9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,6 +2,7 @@ export * from './Form'; export * from './Input'; export * from './PasswordInput'; export * from './NumberInput'; +export * from './DatePicker'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From f8152646f228a16ec7195e08bac26c47421a3143 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 14:30:44 +0000 Subject: [PATCH 09/51] add PhoneInput component with country code selection --- src/components/PhoneInput.tsx | 528 +++++++++++++++++++ src/components/__tests__/PhoneInput.test.tsx | 444 ++++++++++++++++ src/components/index.ts | 1 + 3 files changed, 973 insertions(+) create mode 100644 src/components/PhoneInput.tsx create mode 100644 src/components/__tests__/PhoneInput.test.tsx diff --git a/src/components/PhoneInput.tsx b/src/components/PhoneInput.tsx new file mode 100644 index 0000000..9d9cb46 --- /dev/null +++ b/src/components/PhoneInput.tsx @@ -0,0 +1,528 @@ +/** + * PhoneInput component with country code selection and formatting + */ + +import { forwardRef, useId, useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import type { ValidationRule } from '../validation/types'; +import { useFormField } from '../hooks/useFormField'; +import { useValidation } from '../hooks/useValidation'; +import { useFieldError } from '../hooks/useFieldError'; + +/** + * Country data type + */ +export interface CountryData { + /** ISO 3166-1 alpha-2 country code */ + code: string; + /** Country name */ + name: string; + /** Dial code (e.g., +1) */ + dialCode: string; + /** Phone format pattern (# = digit) */ + format?: string; + /** Flag emoji */ + flag: string; +} + +/** + * Default country codes + */ +export const DEFAULT_COUNTRIES: CountryData[] = [ + { code: 'US', name: 'United States', dialCode: '+1', format: '(###) ###-####', flag: '🇺🇸' }, + { code: 'CA', name: 'Canada', dialCode: '+1', format: '(###) ###-####', flag: '🇨🇦' }, + { code: 'GB', name: 'United Kingdom', dialCode: '+44', format: '#### ######', flag: '🇬🇧' }, + { code: 'DE', name: 'Germany', dialCode: '+49', format: '### #######', flag: '🇩🇪' }, + { code: 'FR', name: 'France', dialCode: '+33', format: '# ## ## ## ##', flag: '🇫🇷' }, + { code: 'IT', name: 'Italy', dialCode: '+39', format: '### ### ####', flag: '🇮🇹' }, + { code: 'ES', name: 'Spain', dialCode: '+34', format: '### ### ###', flag: '🇪🇸' }, + { code: 'AU', name: 'Australia', dialCode: '+61', format: '### ### ###', flag: '🇦🇺' }, + { code: 'JP', name: 'Japan', dialCode: '+81', format: '##-####-####', flag: '🇯🇵' }, + { code: 'CN', name: 'China', dialCode: '+86', format: '### #### ####', flag: '🇨🇳' }, + { code: 'IN', name: 'India', dialCode: '+91', format: '##### #####', flag: '🇮🇳' }, + { code: 'BR', name: 'Brazil', dialCode: '+55', format: '(##) #####-####', flag: '🇧🇷' }, + { code: 'MX', name: 'Mexico', dialCode: '+52', format: '## #### ####', flag: '🇲🇽' }, + { code: 'KR', name: 'South Korea', dialCode: '+82', format: '##-####-####', flag: '🇰🇷' }, + { code: 'RU', name: 'Russia', dialCode: '+7', format: '### ###-##-##', flag: '🇷🇺' }, + { code: 'NL', name: 'Netherlands', dialCode: '+31', format: '# ########', flag: '🇳🇱' }, + { code: 'SE', name: 'Sweden', dialCode: '+46', format: '##-### ## ##', flag: '🇸🇪' }, + { code: 'CH', name: 'Switzerland', dialCode: '+41', format: '## ### ## ##', flag: '🇨🇭' }, + { code: 'PL', name: 'Poland', dialCode: '+48', format: '### ### ###', flag: '🇵🇱' }, + { code: 'AT', name: 'Austria', dialCode: '+43', format: '### ######', flag: '🇦🇹' }, +]; + +/** + * Props for PhoneInput component + */ +export interface PhoneInputProps { + /** Input name attribute */ + name: string; + /** Label text */ + label?: string; + /** Placeholder text */ + placeholder?: string; + /** Default country code */ + defaultCountry?: string; + /** Default phone number (without country code) */ + defaultValue?: string; + /** Whether field is required */ + required?: boolean; + /** Whether field is disabled */ + disabled?: boolean; + /** Whether field is read-only */ + readOnly?: boolean; + /** Custom CSS class name for the container */ + className?: string; + /** Custom CSS class name for the input element */ + inputClassName?: string; + /** Validation rules */ + validationRules?: ValidationRule[]; + /** When to validate */ + validateOn?: 'change' | 'blur' | 'submit'; + /** Debounce validation (ms) */ + debounce?: number; + /** Show error message */ + showError?: boolean; + /** Auto-dismiss errors after delay (ms) */ + autoDismissError?: number; + /** Hint or help text */ + hint?: string; + /** Available countries (defaults to common countries) */ + countries?: CountryData[]; + /** Show country dropdown */ + showCountrySelect?: boolean; + /** Show country flag */ + showFlag?: boolean; + /** Auto-format phone number as user types */ + autoFormat?: boolean; + /** Change handler (returns full phone with country code) */ + onChange?: (value: string, country: CountryData) => void; + /** Blur handler */ + onBlur?: () => void; + /** Focus handler */ + onFocus?: () => void; + /** Country change handler */ + onCountryChange?: (country: CountryData) => void; + /** Validation change handler */ + onValidationChange?: (isValid: boolean) => void; +} + +/** + * Format a phone number based on country format + */ +export function formatPhoneNumber(phoneNumber: string, format?: string): string { + if (!format) return phoneNumber; + + // Remove all non-digits + const digits = phoneNumber.replace(/\D/g, ''); + if (!digits) return ''; + + let result = ''; + let digitIndex = 0; + + for (let i = 0; i < format.length && digitIndex < digits.length; i++) { + if (format[i] === '#') { + result += digits[digitIndex]; + digitIndex++; + } else { + result += format[i]; + } + } + + // Add remaining digits if any + if (digitIndex < digits.length) { + result += digits.slice(digitIndex); + } + + return result; +} + +/** + * Parse a phone number to raw digits + */ +export function parsePhoneNumber(phoneNumber: string): string { + return phoneNumber.replace(/\D/g, ''); +} + +/** + * Get full phone number with country code + */ +export function getFullPhoneNumber(phoneNumber: string, country: CountryData): string { + const digits = parsePhoneNumber(phoneNumber); + if (!digits) return ''; + return `${country.dialCode}${digits}`; +} + +/** + * Chevron down icon + */ +function ChevronDownIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Country dropdown component + */ +interface CountryDropdownProps { + countries: CountryData[]; + selectedCountry: CountryData; + onSelect: (country: CountryData) => void; + disabled?: boolean; + showFlag?: boolean; +} + +function CountryDropdown({ + countries, + selectedCountry, + onSelect, + disabled = false, + showFlag = true, +}: CountryDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Filter countries based on search + const filteredCountries = useMemo(() => { + if (!search) return countries; + const lower = search.toLowerCase(); + return countries.filter( + (c) => + c.name.toLowerCase().includes(lower) || + c.dialCode.includes(search) || + c.code.toLowerCase().includes(lower), + ); + }, [countries, search]); + + // Close on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearch(''); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + // Focus search input when opened + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + const handleSelect = useCallback( + (country: CountryData) => { + onSelect(country); + setIsOpen(false); + setSearch(''); + }, + [onSelect], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + } else if (e.key === 'Enter' && filteredCountries.length > 0) { + handleSelect(filteredCountries[0]); + } + }, + [filteredCountries, handleSelect], + ); + + return ( +
+ + + {isOpen && ( +
+
+ setSearch(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search countries..." + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ {filteredCountries.map((country) => ( + + ))} + {filteredCountries.length === 0 && ( +
No countries found
+ )} +
+
+ )} +
+ ); +} + +/** + * PhoneInput component with country code selection and formatting + */ +export const PhoneInput = forwardRef( + ( + { + name, + label, + placeholder = 'Enter phone number', + defaultCountry = 'US', + defaultValue = '', + required = false, + disabled = false, + readOnly = false, + className = '', + inputClassName = '', + validationRules = [], + validateOn = 'blur', + debounce, + showError = true, + autoDismissError, + hint, + countries = DEFAULT_COUNTRIES, + showCountrySelect = true, + showFlag = true, + autoFormat = true, + onChange, + onBlur, + onFocus, + onCountryChange, + onValidationChange, + }, + ref, + ) => { + const generatedId = useId(); + const fieldId = `phone-${name}-${generatedId}`; + const errorId = `${fieldId}-error`; + const hintId = hint ? `${fieldId}-hint` : undefined; + + // Internal ref + const inputRef = useRef(null); + + // Find initial country + const initialCountry = useMemo( + () => countries.find((c) => c.code === defaultCountry) || countries[0], + [countries, defaultCountry], + ); + + // State + const [selectedCountry, setSelectedCountry] = useState(initialCountry); + const [phoneNumber, setPhoneNumber] = useState(() => + autoFormat ? formatPhoneNumber(defaultValue, initialCountry.format) : defaultValue, + ); + + // Display value + const displayValue = phoneNumber; + + // Field state management + const { + isTouched, + handleBlur: fieldBlur, + handleFocus, + } = useFormField({ + initialValue: displayValue, + disabled, + readOnly, + onBlur: () => { + onBlur?.(); + if (validateOn === 'blur') { + const fullNumber = getFullPhoneNumber(phoneNumber, selectedCountry); + validate(fullNumber); + } + }, + onFocus, + }); + + // Validation + const { errors, isValid, validate } = useValidation({ + rules: validationRules, + debounce, + }); + + // Notify parent of validation changes + useEffect(() => { + if (onValidationChange) { + onValidationChange(isValid); + } + }, [isValid, onValidationChange]); + + // Error handling + const { error, setErrors } = useFieldError({ + fieldName: name, + autoDismiss: autoDismissError, + }); + + // Sync validation errors to field errors + if (errors.length > 0 && error !== errors[0]) { + setErrors(errors); + } else if (errors.length === 0 && error !== null) { + setErrors([]); + } + + // Handle input change + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const rawValue = e.target.value; + const formatted = autoFormat + ? formatPhoneNumber(rawValue, selectedCountry.format) + : rawValue; + + setPhoneNumber(formatted); + + const fullNumber = getFullPhoneNumber(formatted, selectedCountry); + onChange?.(fullNumber, selectedCountry); + + if (validateOn === 'change') { + validate(fullNumber); + } + }, + [autoFormat, selectedCountry, onChange, validateOn, validate], + ); + + // Handle country change + const handleCountryChange = useCallback( + (country: CountryData) => { + setSelectedCountry(country); + onCountryChange?.(country); + + // Reformat phone number with new country format + if (autoFormat && phoneNumber) { + const formatted = formatPhoneNumber(phoneNumber, country.format); + setPhoneNumber(formatted); + } + + const fullNumber = getFullPhoneNumber(phoneNumber, country); + onChange?.(fullNumber, country); + }, + [autoFormat, phoneNumber, onChange, onCountryChange], + ); + + // Handle blur + const handleBlur = useCallback(() => { + fieldBlur(); + }, [fieldBlur]); + + const hasError = isTouched && error !== null; + const showHint = hint && !hasError; + + return ( +
+ {label ? ( + + ) : ( + + )} +
+ {showCountrySelect && ( + + )} + { + (inputRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + id={fieldId} + name={name} + type="tel" + value={displayValue} + onChange={handleInputChange} + onBlur={handleBlur} + onFocus={handleFocus} + placeholder={placeholder} + required={required} + disabled={disabled} + readOnly={readOnly} + className={`formkit-phone-input ${inputClassName} flex-1 px-3 py-2 sm:px-4 sm:py-2.5 text-sm sm:text-base border border-gray-300 ${showCountrySelect ? 'rounded-r border-l-0' : 'rounded'} transition-all duration-150 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:border-gray-300 ${hasError ? 'formkit-phone-error border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400' : ''} ${isTouched && isValid ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400' : ''}`} + aria-invalid={hasError} + aria-describedby={ + [hasError ? errorId : undefined, showHint ? hintId : undefined] + .filter(Boolean) + .join(' ') || undefined + } + /> +
+ {showHint && ( +
+ {hint} +
+ )} + {showError && hasError && ( + + )} +
+ ); + }, +); + +PhoneInput.displayName = 'PhoneInput'; diff --git a/src/components/__tests__/PhoneInput.test.tsx b/src/components/__tests__/PhoneInput.test.tsx new file mode 100644 index 0000000..e716cd7 --- /dev/null +++ b/src/components/__tests__/PhoneInput.test.tsx @@ -0,0 +1,444 @@ +/** + * Tests for PhoneInput component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + PhoneInput, + formatPhoneNumber, + parsePhoneNumber, + getFullPhoneNumber, + DEFAULT_COUNTRIES, + type CountryData, +} from '../PhoneInput'; +import { required } from '../../validation/validators'; + +describe('PhoneInput', () => { + describe('basic rendering', () => { + it('renders input with name', () => { + render(); + const input = document.querySelector('input[name="phone"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders with tel type', () => { + render(); + const input = document.querySelector('input[name="phone"]'); + expect(input).toHaveAttribute('type', 'tel'); + }); + + it('renders with placeholder', () => { + render(); + const input = screen.getByPlaceholderText('Your phone'); + expect(input).toBeInTheDocument(); + }); + + it('renders with default value', () => { + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + // Should be formatted for US + expect(input.value).toBe('(555) 123-4567'); + }); + }); + + describe('label and accessibility', () => { + it('renders with label', () => { + render(); + const label = screen.getByText('Phone Number'); + expect(label).toBeInTheDocument(); + }); + + it('associates label with input', () => { + render(); + const input = screen.getByLabelText('Phone Number'); + expect(input).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + const required = screen.getAllByText('*'); + expect(required.length).toBeGreaterThan(0); + }); + + it('renders with hint text', () => { + render(); + const hint = screen.getByText('We will never share your phone'); + expect(hint).toBeInTheDocument(); + }); + + it('has proper sr-only label when no visible label', () => { + render(); + const label = document.querySelector('label.sr-only'); + expect(label).toBeInTheDocument(); + }); + }); + + describe('country selector', () => { + it('renders country dropdown by default', () => { + render(); + const dialCode = screen.getByText('+1'); + expect(dialCode).toBeInTheDocument(); + }); + + it('shows US flag by default', () => { + render(); + const flag = screen.getByText('🇺🇸'); + expect(flag).toBeInTheDocument(); + }); + + it('can hide country selector', () => { + render(); + const dialCode = screen.queryByText('+1'); + expect(dialCode).not.toBeInTheDocument(); + }); + + it('opens country dropdown on click', async () => { + const user = userEvent.setup(); + render(); + + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + + it('shows search input in dropdown', async () => { + const user = userEvent.setup(); + render(); + + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + + const searchInput = screen.getByPlaceholderText('Search countries...'); + expect(searchInput).toBeInTheDocument(); + }); + + it('filters countries on search', async () => { + const user = userEvent.setup(); + render(); + + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + + const searchInput = screen.getByPlaceholderText('Search countries...'); + await user.type(searchInput, 'Germany'); + + const germanyOption = screen.getByRole('option', { name: /germany/i }); + expect(germanyOption).toBeInTheDocument(); + }); + + it('changes country on selection', async () => { + const user = userEvent.setup(); + render(); + + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + + const ukOption = screen.getByRole('option', { name: /united kingdom/i }); + await user.click(ukOption); + + // Should now show UK dial code + expect(screen.getByText('+44')).toBeInTheDocument(); + expect(screen.getByText('🇬🇧')).toBeInTheDocument(); + }); + + it('calls onCountryChange when country changes', async () => { + const user = userEvent.setup(); + const onCountryChange = vi.fn(); + render(); + + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + + const ukOption = screen.getByRole('option', { name: /united kingdom/i }); + await user.click(ukOption); + + expect(onCountryChange).toHaveBeenCalledWith( + expect.objectContaining({ code: 'GB', dialCode: '+44' }), + ); + }); + + it('closes dropdown on escape', async () => { + const user = userEvent.setup(); + render(); + + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + it('uses defaultCountry prop', () => { + render(); + expect(screen.getByText('+44')).toBeInTheDocument(); + expect(screen.getByText('🇬🇧')).toBeInTheDocument(); + }); + }); + + describe('phone formatting', () => { + it('formats US phone number as user types', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.type(input, '5551234567'); + expect(input.value).toBe('(555) 123-4567'); + }); + + it('formats UK phone number correctly', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.type(input, '7911123456'); + expect(input.value).toBe('7911 123456'); + }); + + it('can disable auto-formatting', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.type(input, '5551234567'); + expect(input.value).toBe('5551234567'); + }); + + it('reformats when country changes', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + // Initially formatted for US + expect(input.value).toBe('(555) 123-4567'); + + // Change to UK + const countryButton = screen.getByRole('button', { name: /selected country/i }); + await user.click(countryButton); + const ukOption = screen.getByRole('option', { name: /united kingdom/i }); + await user.click(ukOption); + + // Should reformat for UK + expect(input.value).toBe('5551 234567'); + }); + }); + + describe('user interaction', () => { + it('handles user input', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.type(input, '1234567890'); + expect(input.value).toBe('1234567890'); + }); + + it('calls onChange with full phone number', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.type(input, '5551234567'); + + // Should be called with full number including country code + expect(onChange).toHaveBeenCalledWith( + '+15551234567', + expect.objectContaining({ code: 'US' }), + ); + }); + + it('calls onBlur handler', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('calls onFocus handler', async () => { + const user = userEvent.setup(); + const onFocus = vi.fn(); + render(); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.click(input); + expect(onFocus).toHaveBeenCalled(); + }); + }); + + describe('validation', () => { + it('shows required error', async () => { + const user = userEvent.setup(); + render( + , + ); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.click(input); + await user.tab(); + + const error = await screen.findByRole('alert'); + expect(error).toBeInTheDocument(); + }); + + it('calls onValidationChange callback', async () => { + const user = userEvent.setup(); + const onValidationChange = vi.fn(); + render( + , + ); + const input = document.querySelector('input[name="phone"]') as HTMLInputElement; + + await user.type(input, '5551234567'); + expect(onValidationChange).toHaveBeenCalled(); + }); + }); + + describe('disabled and readonly states', () => { + it('renders disabled input', () => { + render(); + const input = document.querySelector('input[name="phone"]'); + expect(input).toBeDisabled(); + }); + + it('renders readonly input', () => { + render(); + const input = document.querySelector('input[name="phone"]'); + expect(input).toHaveAttribute('readonly'); + }); + + it('disables country dropdown when disabled', () => { + render(); + const countryButton = screen.getByRole('button', { name: /selected country/i }); + expect(countryButton).toBeDisabled(); + }); + }); + + describe('custom countries', () => { + it('accepts custom country list', () => { + const customCountries: CountryData[] = [ + { code: 'XX', name: 'Test Country', dialCode: '+99', flag: '🏳️' }, + ]; + render(); + + expect(screen.getByText('+99')).toBeInTheDocument(); + expect(screen.getByText('🏳️')).toBeInTheDocument(); + }); + }); +}); + +describe('formatPhoneNumber', () => { + it('formats with US pattern', () => { + expect(formatPhoneNumber('5551234567', '(###) ###-####')).toBe('(555) 123-4567'); + }); + + it('formats with UK pattern', () => { + expect(formatPhoneNumber('7911123456', '#### ######')).toBe('7911 123456'); + }); + + it('returns raw digits when no format provided', () => { + expect(formatPhoneNumber('5551234567')).toBe('5551234567'); + }); + + it('handles partial input', () => { + expect(formatPhoneNumber('555', '(###) ###-####')).toBe('(555'); + }); + + it('handles extra digits beyond format', () => { + expect(formatPhoneNumber('55512345678901', '(###) ###-####')).toBe('(555) 123-45678901'); + }); + + it('returns empty string for empty input', () => { + expect(formatPhoneNumber('', '(###) ###-####')).toBe(''); + }); + + it('strips non-digits before formatting', () => { + expect(formatPhoneNumber('(555) 123-4567', '(###) ###-####')).toBe('(555) 123-4567'); + }); +}); + +describe('parsePhoneNumber', () => { + it('extracts digits from formatted number', () => { + expect(parsePhoneNumber('(555) 123-4567')).toBe('5551234567'); + }); + + it('handles plain digits', () => { + expect(parsePhoneNumber('5551234567')).toBe('5551234567'); + }); + + it('handles international format', () => { + expect(parsePhoneNumber('+1-555-123-4567')).toBe('15551234567'); + }); + + it('returns empty string for non-numeric input', () => { + expect(parsePhoneNumber('abc')).toBe(''); + }); +}); + +describe('getFullPhoneNumber', () => { + const usCountry: CountryData = { + code: 'US', + name: 'United States', + dialCode: '+1', + flag: '🇺🇸', + }; + + it('combines country code with phone number', () => { + expect(getFullPhoneNumber('5551234567', usCountry)).toBe('+15551234567'); + }); + + it('handles formatted input', () => { + expect(getFullPhoneNumber('(555) 123-4567', usCountry)).toBe('+15551234567'); + }); + + it('returns empty for empty phone', () => { + expect(getFullPhoneNumber('', usCountry)).toBe(''); + }); +}); + +describe('DEFAULT_COUNTRIES', () => { + it('includes common countries', () => { + const codes = DEFAULT_COUNTRIES.map((c) => c.code); + expect(codes).toContain('US'); + expect(codes).toContain('GB'); + expect(codes).toContain('DE'); + expect(codes).toContain('FR'); + expect(codes).toContain('JP'); + }); + + it('has valid dial codes', () => { + DEFAULT_COUNTRIES.forEach((country) => { + expect(country.dialCode).toMatch(/^\+\d+$/); + }); + }); + + it('has flags for all countries', () => { + DEFAULT_COUNTRIES.forEach((country) => { + expect(country.flag).toBeTruthy(); + }); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index ad154a9..c0353bb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,7 @@ export * from './Input'; export * from './PasswordInput'; export * from './NumberInput'; export * from './DatePicker'; +export * from './PhoneInput'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From f4524f162412583bc75525d4a5457654e7ccdcc1 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 14:41:16 +0000 Subject: [PATCH 10/51] feat: add FileInput component with drag-and-drop and preview --- src/components/FileInput.tsx | 598 ++++++++++++++++++++ src/components/__tests__/FileInput.test.tsx | 533 +++++++++++++++++ src/components/index.ts | 1 + 3 files changed, 1132 insertions(+) create mode 100644 src/components/FileInput.tsx create mode 100644 src/components/__tests__/FileInput.test.tsx diff --git a/src/components/FileInput.tsx b/src/components/FileInput.tsx new file mode 100644 index 0000000..683ca82 --- /dev/null +++ b/src/components/FileInput.tsx @@ -0,0 +1,598 @@ +/** + * FileInput component with drag-and-drop and preview + */ + +import { forwardRef, useId, useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import type { ValidationRule } from '../validation/types'; +import { useValidation } from '../hooks/useValidation'; +import { useFieldError } from '../hooks/useFieldError'; + +/** + * File preview data + */ +export interface FilePreview { + /** File object */ + file: File; + /** Preview URL (for images) */ + previewUrl?: string; + /** Unique ID */ + id: string; +} + +/** + * Accepted file types + */ +export type AcceptedFileTypes = + | 'image/*' + | 'video/*' + | 'audio/*' + | 'application/pdf' + | '.doc,.docx' + | '.xls,.xlsx' + | string; + +/** + * Props for FileInput component + */ +export interface FileInputProps { + /** Input name attribute */ + name: string; + /** Label text */ + label?: string; + /** Whether field is required */ + required?: boolean; + /** Whether field is disabled */ + disabled?: boolean; + /** Custom CSS class name for the container */ + className?: string; + /** Validation rules */ + validationRules?: ValidationRule[]; + /** Show error message */ + showError?: boolean; + /** Auto-dismiss errors after delay (ms) */ + autoDismissError?: number; + /** Hint or help text */ + hint?: string; + /** Accepted file types */ + accept?: AcceptedFileTypes | AcceptedFileTypes[]; + /** Allow multiple files */ + multiple?: boolean; + /** Maximum file size in bytes */ + maxSize?: number; + /** Maximum number of files */ + maxFiles?: number; + /** Show file preview (for images) */ + showPreview?: boolean; + /** Show file list */ + showFileList?: boolean; + /** Custom drop zone text */ + dropZoneText?: string; + /** Custom browse button text */ + browseText?: string; + /** Change handler */ + onChange?: (files: File[]) => void; + /** Error handler for file validation */ + onError?: (error: string) => void; + /** File remove handler */ + onRemove?: (file: File) => void; +} + +/** + * Format file size for display + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`; +} + +/** + * Get file extension + */ +export function getFileExtension(filename: string): string { + const parts = filename.split('.'); + return parts.length > 1 ? parts.pop()!.toLowerCase() : ''; +} + +/** + * Check if file is an image + */ +export function isImageFile(file: File): boolean { + return file.type.startsWith('image/'); +} + +/** + * Generate unique ID + */ +function generateId(): string { + return Math.random().toString(36).substring(2, 11); +} + +/** + * Upload cloud icon + */ +function UploadIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * X close icon + */ +function CloseIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * Document icon + */ +function DocumentIcon({ className = '' }: { className?: string }) { + return ( + + ); +} + +/** + * File preview component + */ +interface PreviewItemProps { + preview: FilePreview; + onRemove: (id: string) => void; + disabled?: boolean; +} + +function PreviewItem({ preview, onRemove, disabled }: PreviewItemProps) { + const isImage = preview.previewUrl && isImageFile(preview.file); + + return ( +
+ {isImage ? ( + {preview.file.name} + ) : ( +
+ +
+ )} +
+

{preview.file.name}

+

{formatFileSize(preview.file.size)}

+
+ {!disabled && ( + + )} +
+ ); +} + +/** + * FileInput component with drag-and-drop and preview + */ +export const FileInput = forwardRef( + ( + { + name, + label, + required = false, + disabled = false, + className = '', + validationRules = [], + showError = true, + autoDismissError, + hint, + accept, + multiple = false, + maxSize, + maxFiles, + showPreview = true, + showFileList = true, + dropZoneText = 'Drag and drop files here, or', + browseText = 'browse', + onChange, + onError, + onRemove, + }, + ref, + ) => { + const generatedId = useId(); + const fieldId = `file-${name}-${generatedId}`; + const errorId = `${fieldId}-error`; + const hintId = hint ? `${fieldId}-hint` : undefined; + + // Internal ref + const inputRef = useRef(null); + + // State + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isTouched, setIsTouched] = useState(false); + + // Accept string + const acceptString = useMemo(() => { + if (!accept) return undefined; + return Array.isArray(accept) ? accept.join(',') : accept; + }, [accept]); + + // Validation + const { errors, validate } = useValidation({ + rules: validationRules, + }); + + // Error handling + const { error, setErrors } = useFieldError({ + fieldName: name, + autoDismiss: autoDismissError, + }); + + // Sync validation errors to field errors + if (errors.length > 0 && error !== errors[0]) { + setErrors(errors); + } else if (errors.length === 0 && error !== null) { + setErrors([]); + } + + // Create preview URL for image files + const createPreview = useCallback((file: File): FilePreview => { + const preview: FilePreview = { + file, + id: generateId(), + }; + + if (isImageFile(file)) { + preview.previewUrl = URL.createObjectURL(file); + } + + return preview; + }, []); + + // Clean up preview URLs on unmount + useEffect(() => { + return () => { + files.forEach((preview) => { + if (preview.previewUrl) { + URL.revokeObjectURL(preview.previewUrl); + } + }); + }; + }, [files]); + + // Validate file + const validateFile = useCallback( + (file: File): string | null => { + // Check file size + if (maxSize && file.size > maxSize) { + return `File "${file.name}" exceeds maximum size of ${formatFileSize(maxSize)}`; + } + + // Check file type + if (accept) { + const acceptList = Array.isArray(accept) ? accept : [accept]; + const isAccepted = acceptList.some((type) => { + if (type.startsWith('.')) { + // Extension check + const extensions = type.split(',').map((e) => e.trim().slice(1).toLowerCase()); + return extensions.includes(getFileExtension(file.name)); + } else if (type.endsWith('/*')) { + // MIME type wildcard (e.g., image/*) + const mimePrefix = type.slice(0, -2); + return file.type.startsWith(mimePrefix); + } else { + // Exact MIME type + return file.type === type; + } + }); + + if (!isAccepted) { + return `File "${file.name}" is not an accepted file type`; + } + } + + return null; + }, + [accept, maxSize], + ); + + // Add files + const addFiles = useCallback( + (newFiles: FileList | File[]) => { + setIsTouched(true); + const fileArray = Array.from(newFiles); + const validFiles: FilePreview[] = []; + const errors: string[] = []; + + // Check max files limit + const currentCount = files.length; + const totalCount = currentCount + fileArray.length; + + if (maxFiles && totalCount > maxFiles) { + const errorMsg = `Maximum ${maxFiles} file${maxFiles > 1 ? 's' : ''} allowed`; + errors.push(errorMsg); + onError?.(errorMsg); + } + + const maxToAdd = maxFiles ? Math.max(0, maxFiles - currentCount) : fileArray.length; + const filesToProcess = fileArray.slice(0, maxToAdd); + + filesToProcess.forEach((file) => { + const validationError = validateFile(file); + if (validationError) { + errors.push(validationError); + onError?.(validationError); + } else { + validFiles.push(createPreview(file)); + } + }); + + if (validFiles.length > 0) { + const newFilesList = multiple ? [...files, ...validFiles] : validFiles; + setFiles(newFilesList); + onChange?.(newFilesList.map((f) => f.file)); + validate(newFilesList.length > 0 ? 'has-files' : ''); + } + + if (errors.length > 0) { + setErrors(errors); + } + }, + [ + files, + maxFiles, + multiple, + validateFile, + createPreview, + onChange, + validate, + onError, + setErrors, + ], + ); + + // Remove file + const removeFile = useCallback( + (id: string) => { + setFiles((prev) => { + const toRemove = prev.find((f) => f.id === id); + if (toRemove?.previewUrl) { + URL.revokeObjectURL(toRemove.previewUrl); + } + if (toRemove) { + onRemove?.(toRemove.file); + } + + const newList = prev.filter((f) => f.id !== id); + onChange?.(newList.map((f) => f.file)); + validate(newList.length > 0 ? 'has-files' : ''); + return newList; + }); + }, + [onChange, onRemove, validate], + ); + + // Handle input change + const handleChange = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + addFiles(e.target.files); + } + // Reset input value to allow selecting the same file again + e.target.value = ''; + }, + [addFiles], + ); + + // Handle drag events + const handleDragEnter = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragging(true); + } + }, + [disabled], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragging(true); + } + }, + [disabled], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + if (disabled) return; + + const droppedFiles = e.dataTransfer.files; + if (droppedFiles && droppedFiles.length > 0) { + addFiles(droppedFiles); + } + }, + [disabled, addFiles], + ); + + // Handle browse click + const handleBrowseClick = useCallback(() => { + if (!disabled) { + inputRef.current?.click(); + } + }, [disabled]); + + const hasError = isTouched && error !== null; + const showHint = hint && !hasError; + + return ( +
+ {label && ( + + )} + + {/* Hidden file input */} + { + (inputRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + id={fieldId} + name={name} + type="file" + accept={acceptString} + multiple={multiple} + disabled={disabled} + onChange={handleChange} + className="sr-only" + aria-describedby={ + [hasError ? errorId : undefined, showHint ? hintId : undefined] + .filter(Boolean) + .join(' ') || undefined + } + /> + + {/* Drop zone */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleBrowseClick(); + } + }} + > + +

+ {dropZoneText}{' '} + {browseText} +

+ {accept && ( +

+ Accepted: {Array.isArray(accept) ? accept.join(', ') : accept} +

+ )} + {maxSize && ( +

Max size: {formatFileSize(maxSize)}

+ )} +
+ + {/* File list / previews */} + {showFileList && files.length > 0 && ( +
+ {files.map((preview) => ( + + ))} +
+ )} + + {showHint && ( +
+ {hint} +
+ )} + {showError && hasError && ( + + )} +
+ ); + }, +); + +FileInput.displayName = 'FileInput'; diff --git a/src/components/__tests__/FileInput.test.tsx b/src/components/__tests__/FileInput.test.tsx new file mode 100644 index 0000000..c23d3ed --- /dev/null +++ b/src/components/__tests__/FileInput.test.tsx @@ -0,0 +1,533 @@ +/** + * Tests for FileInput component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FileInput, formatFileSize, getFileExtension, isImageFile } from '../FileInput'; +import { required } from '../../validation/validators'; + +// Helper to create mock files +function createMockFile(name: string, size: number, type: string): File { + const file = new File(['a'.repeat(size)], name, { type }); + Object.defineProperty(file, 'size', { value: size }); + return file; +} + +// Helper to create DataTransfer for drag events +function createDataTransfer(files: File[]): DataTransfer { + const dataTransfer = { + files: files, + items: files.map((file) => ({ + kind: 'file', + type: file.type, + getAsFile: () => file, + })), + types: ['Files'], + } as unknown as DataTransfer; + return dataTransfer; +} + +describe('FileInput', () => { + describe('basic rendering', () => { + it('renders with name', () => { + render(); + const input = document.querySelector('input[name="files"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders hidden file input', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveClass('sr-only'); + }); + + it('renders drop zone', () => { + render(); + const dropzone = document.querySelector('.formkit-dropzone'); + expect(dropzone).toBeInTheDocument(); + }); + + it('renders upload icon', () => { + render(); + const svg = document.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders default drop zone text', () => { + render(); + expect(screen.getByText(/drag and drop files here/i)).toBeInTheDocument(); + }); + + it('renders browse text', () => { + render(); + expect(screen.getByText('browse')).toBeInTheDocument(); + }); + }); + + describe('label and accessibility', () => { + it('renders with label', () => { + render(); + const label = screen.getByText('Upload Files'); + expect(label).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + const required = screen.getAllByText('*'); + expect(required.length).toBeGreaterThan(0); + }); + + it('renders with hint text', () => { + render(); + const hint = screen.getByText('Max 5MB per file'); + expect(hint).toBeInTheDocument(); + }); + + it('drop zone has button role', () => { + render(); + const dropzone = screen.getByRole('button', { name: /upload files/i }); + expect(dropzone).toBeInTheDocument(); + }); + + it('drop zone is focusable', () => { + render(); + const dropzone = document.querySelector('.formkit-dropzone'); + expect(dropzone).toHaveAttribute('tabindex', '0'); + }); + }); + + describe('file selection', () => { + it('accepts single file by default', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).not.toHaveAttribute('multiple'); + }); + + it('accepts multiple files when configured', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('multiple'); + }); + + it('sets accept attribute', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('accept', 'image/*'); + }); + + it('sets accept attribute from array', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('accept', 'image/*,.pdf'); + }); + + it('shows accepted file types', () => { + render(); + expect(screen.getByText(/accepted: image\/\*/i)).toBeInTheDocument(); + }); + + it('shows max size info', () => { + render(); + expect(screen.getByText(/max size: 5 MB/i)).toBeInTheDocument(); + }); + }); + + describe('file upload via input', () => { + it('calls onChange when file is selected', async () => { + const onChange = vi.fn(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + expect(onChange).toHaveBeenCalledWith([expect.any(File)]); + }); + + it('displays selected file in list', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('document.pdf', 2048, 'application/pdf'); + await userEvent.upload(input, file); + + expect(screen.getByText('document.pdf')).toBeInTheDocument(); + }); + + it('displays file size', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + expect(screen.getByText('1 KB')).toBeInTheDocument(); + }); + + it('handles multiple files', async () => { + const onChange = vi.fn(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const files = [ + createMockFile('file1.txt', 1024, 'text/plain'), + createMockFile('file2.txt', 2048, 'text/plain'), + ]; + await userEvent.upload(input, files); + + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([expect.any(File), expect.any(File)]), + ); + }); + }); + + describe('drag and drop', () => { + it('highlights on drag enter', () => { + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + fireEvent.dragEnter(dropzone); + + expect(dropzone).toHaveClass('border-blue-500'); + expect(dropzone).toHaveClass('bg-blue-50'); + }); + + it('removes highlight on drag leave', () => { + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + fireEvent.dragEnter(dropzone); + fireEvent.dragLeave(dropzone); + + expect(dropzone).not.toHaveClass('border-blue-500'); + }); + + it('accepts dropped files', () => { + const onChange = vi.fn(); + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + const file = createMockFile('dropped.txt', 1024, 'text/plain'); + const dataTransfer = createDataTransfer([file]); + + fireEvent.drop(dropzone, { dataTransfer }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('prevents default on drag over', () => { + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + const event = new Event('dragover', { bubbles: true }); + event.preventDefault = vi.fn(); + + dropzone.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('file removal', () => { + it('shows remove button for each file', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + const removeButton = screen.getByRole('button', { name: /remove test.txt/i }); + expect(removeButton).toBeInTheDocument(); + }); + + it('removes file on button click', async () => { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + expect(screen.getByText('test.txt')).toBeInTheDocument(); + + const removeButton = screen.getByRole('button', { name: /remove test.txt/i }); + await user.click(removeButton); + + expect(screen.queryByText('test.txt')).not.toBeInTheDocument(); + }); + + it('calls onRemove when file is removed', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + const removeButton = screen.getByRole('button', { name: /remove test.txt/i }); + await user.click(removeButton); + + expect(onRemove).toHaveBeenCalledWith(expect.any(File)); + }); + }); + + describe('validation', () => { + it('validates max file size', async () => { + const onError = vi.fn(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('large.txt', 2048, 'text/plain'); + await userEvent.upload(input, file); + + expect(onError).toHaveBeenCalledWith(expect.stringContaining('exceeds maximum size')); + }); + + it('validates max files count', async () => { + const onError = vi.fn(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const files = [ + createMockFile('file1.txt', 100, 'text/plain'), + createMockFile('file2.txt', 100, 'text/plain'), + ]; + await userEvent.upload(input, files); + + expect(onError).toHaveBeenCalledWith(expect.stringContaining('Maximum 1 file')); + }); + + it('shows required error', async () => { + const user = userEvent.setup(); + render( + , + ); + + // Trigger validation by uploading and removing a file + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + const removeButton = screen.getByRole('button', { name: /remove test.txt/i }); + await user.click(removeButton); + + // Should show error since no files + const error = await screen.findByRole('alert'); + expect(error).toBeInTheDocument(); + }); + }); + + describe('file preview', () => { + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + beforeAll(() => { + URL.createObjectURL = vi.fn(() => 'blob:test-url'); + URL.revokeObjectURL = vi.fn(); + }); + + afterAll(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + it('shows image preview for image files', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('photo.jpg', 1024, 'image/jpeg'); + await userEvent.upload(input, file); + + const img = document.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'blob:test-url'); + }); + + it('shows document icon for non-image files', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('document.pdf', 1024, 'application/pdf'); + await userEvent.upload(input, file); + + // Should show document icon (svg), not img + const img = document.querySelector('img'); + expect(img).not.toBeInTheDocument(); + }); + + it('can hide file list', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + // File list should not show + expect(screen.queryByText('test.txt')).not.toBeInTheDocument(); + }); + }); + + describe('disabled state', () => { + it('disables file input', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).toBeDisabled(); + }); + + it('applies disabled styles to drop zone', () => { + render(); + const dropzone = document.querySelector('.formkit-dropzone'); + expect(dropzone).toHaveClass('opacity-50'); + expect(dropzone).toHaveClass('cursor-not-allowed'); + }); + + it('prevents drag and drop when disabled', () => { + const onChange = vi.fn(); + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + const dataTransfer = createDataTransfer([file]); + + fireEvent.drop(dropzone, { dataTransfer }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('hides remove buttons when disabled', async () => { + const { rerender } = render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + const file = createMockFile('test.txt', 1024, 'text/plain'); + await userEvent.upload(input, file); + + expect(screen.getByRole('button', { name: /remove test.txt/i })).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByRole('button', { name: /remove test.txt/i })).not.toBeInTheDocument(); + }); + }); + + describe('custom text', () => { + it('accepts custom drop zone text', () => { + render(); + expect(screen.getByText(/drop your images here/i)).toBeInTheDocument(); + }); + + it('accepts custom browse text', () => { + render(); + expect(screen.getByText('select files')).toBeInTheDocument(); + }); + }); + + describe('keyboard interaction', () => { + it('opens file dialog on Enter key', async () => { + const user = userEvent.setup(); + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + // Mock click on input + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + + dropzone.focus(); + await user.keyboard('{Enter}'); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('opens file dialog on Space key', async () => { + const user = userEvent.setup(); + render(); + const dropzone = document.querySelector('.formkit-dropzone') as HTMLElement; + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + + dropzone.focus(); + await user.keyboard(' '); + + expect(clickSpy).toHaveBeenCalled(); + }); + }); +}); + +describe('formatFileSize', () => { + it('formats bytes', () => { + expect(formatFileSize(500)).toBe('500 B'); + }); + + it('formats kilobytes', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + }); + + it('formats megabytes', () => { + expect(formatFileSize(1024 * 1024)).toBe('1 MB'); + }); + + it('formats gigabytes', () => { + expect(formatFileSize(1024 * 1024 * 1024)).toBe('1 GB'); + }); + + it('handles decimal values', () => { + expect(formatFileSize(1536)).toBe('1.5 KB'); + }); + + it('returns 0 B for zero', () => { + expect(formatFileSize(0)).toBe('0 B'); + }); +}); + +describe('getFileExtension', () => { + it('extracts extension from filename', () => { + expect(getFileExtension('document.pdf')).toBe('pdf'); + }); + + it('handles multiple dots', () => { + expect(getFileExtension('file.name.txt')).toBe('txt'); + }); + + it('returns lowercase extension', () => { + expect(getFileExtension('IMAGE.PNG')).toBe('png'); + }); + + it('returns empty string for no extension', () => { + expect(getFileExtension('filename')).toBe(''); + }); +}); + +describe('isImageFile', () => { + it('returns true for jpeg', () => { + const file = createMockFile('photo.jpg', 100, 'image/jpeg'); + expect(isImageFile(file)).toBe(true); + }); + + it('returns true for png', () => { + const file = createMockFile('image.png', 100, 'image/png'); + expect(isImageFile(file)).toBe(true); + }); + + it('returns true for gif', () => { + const file = createMockFile('anim.gif', 100, 'image/gif'); + expect(isImageFile(file)).toBe(true); + }); + + it('returns false for pdf', () => { + const file = createMockFile('doc.pdf', 100, 'application/pdf'); + expect(isImageFile(file)).toBe(false); + }); + + it('returns false for text file', () => { + const file = createMockFile('text.txt', 100, 'text/plain'); + expect(isImageFile(file)).toBe(false); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index c0353bb..c0256c4 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ export * from './PasswordInput'; export * from './NumberInput'; export * from './DatePicker'; export * from './PhoneInput'; +export * from './FileInput'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From f59e2e636dc4247bd718047715e45220b72f33f4 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 15:06:49 +0000 Subject: [PATCH 11/51] feat: add ColorPicker component with swatches and RGB/HSL inputs --- src/components/ColorPicker.tsx | 655 ++++++++++++++++++ src/components/__tests__/ColorPicker.test.tsx | 538 ++++++++++++++ src/components/index.ts | 1 + 3 files changed, 1194 insertions(+) create mode 100644 src/components/ColorPicker.tsx create mode 100644 src/components/__tests__/ColorPicker.test.tsx diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..f43cf0f --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,655 @@ +import * as React from 'react'; + +// ============================================================================ +// Color Utility Functions +// ============================================================================ + +/** + * Convert hex color to RGB + */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return null; + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; +} + +/** + * Convert RGB to hex color + */ +export function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(n))); + return clamped.toString(16).padStart(2, '0'); + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** + * Convert RGB to HSL + */ +export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + case b: + h = ((r - g) / d + 4) / 6; + break; + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +/** + * Convert HSL to RGB + */ +export function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + h /= 360; + s /= 100; + l /= 100; + + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255), + }; +} + +/** + * Parse any color string to RGB + */ +export function parseColor(color: string): { r: number; g: number; b: number; a: number } | null { + // Handle hex + const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(color); + if (hexMatch) { + return { + r: parseInt(hexMatch[1], 16), + g: parseInt(hexMatch[2], 16), + b: parseInt(hexMatch[3], 16), + a: hexMatch[4] ? parseInt(hexMatch[4], 16) / 255 : 1, + }; + } + + // Handle short hex + const shortHexMatch = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(color); + if (shortHexMatch) { + return { + r: parseInt(shortHexMatch[1] + shortHexMatch[1], 16), + g: parseInt(shortHexMatch[2] + shortHexMatch[2], 16), + b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16), + a: 1, + }; + } + + // Handle rgb/rgba + const rgbMatch = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec( + color, + ); + if (rgbMatch) { + return { + r: parseInt(rgbMatch[1], 10), + g: parseInt(rgbMatch[2], 10), + b: parseInt(rgbMatch[3], 10), + a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1, + }; + } + + // Handle hsl/hsla + const hslMatch = /^hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec( + color, + ); + if (hslMatch) { + const rgb = hslToRgb( + parseInt(hslMatch[1], 10), + parseInt(hslMatch[2], 10), + parseInt(hslMatch[3], 10), + ); + return { + ...rgb, + a: hslMatch[4] ? parseFloat(hslMatch[4]) : 1, + }; + } + + return null; +} + +/** + * Check if a color is light (for contrast purposes) + */ +export function isLightColor(hex: string): boolean { + const rgb = hexToRgb(hex); + if (!rgb) return true; + // Using relative luminance formula + const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + return luminance > 0.5; +} + +// ============================================================================ +// Default Color Swatches +// ============================================================================ + +export const DEFAULT_SWATCHES = [ + // Reds + '#ef4444', + '#dc2626', + '#b91c1c', + // Oranges + '#f97316', + '#ea580c', + '#c2410c', + // Yellows + '#eab308', + '#ca8a04', + '#a16207', + // Greens + '#22c55e', + '#16a34a', + '#15803d', + // Blues + '#3b82f6', + '#2563eb', + '#1d4ed8', + // Purples + '#a855f7', + '#9333ea', + '#7e22ce', + // Pinks + '#ec4899', + '#db2777', + '#be185d', + // Grays + '#6b7280', + '#4b5563', + '#374151', + // Black & White + '#000000', + '#ffffff', +]; + +// ============================================================================ +// Types +// ============================================================================ + +export type ColorFormat = 'hex' | 'rgb' | 'hsl'; + +export interface ColorPickerProps extends Omit, 'onChange'> { + /** Input name for form submission */ + name: string; + /** Current color value (hex format) */ + value?: string; + /** Default color value */ + defaultValue?: string; + /** Callback when color changes */ + onChange?: (color: string) => void; + /** Label text */ + label?: string; + /** Whether the field is required */ + required?: boolean; + /** Hint text below the input */ + hint?: string; + /** Error message */ + error?: string; + /** Whether the picker is disabled */ + disabled?: boolean; + /** Color format to display */ + format?: ColorFormat; + /** Allow alpha/opacity selection */ + showAlpha?: boolean; + /** Custom color swatches */ + swatches?: string[]; + /** Show preset swatches */ + showSwatches?: boolean; + /** Number of swatch columns */ + swatchColumns?: number; + /** Show RGB sliders */ + showRgbInputs?: boolean; + /** Show HSL inputs */ + showHslInputs?: boolean; + /** Additional class for the container */ + className?: string; + /** Placeholder text for hex input */ + placeholder?: string; +} + +// ============================================================================ +// ColorPicker Component +// ============================================================================ + +export const ColorPicker = React.forwardRef( + ( + { + name, + value: controlledValue, + defaultValue = '#3b82f6', + onChange, + label, + required, + hint, + error: propError, + disabled = false, + format = 'hex', + showAlpha = false, + swatches = DEFAULT_SWATCHES, + showSwatches = true, + swatchColumns = 8, + showRgbInputs = true, + showHslInputs = false, + className = '', + placeholder = '#000000', + ...props + }, + ref, + ) => { + const isControlled = controlledValue !== undefined; + const [internalValue, setInternalValue] = React.useState(defaultValue); + const [hexInput, setHexInput] = React.useState(defaultValue); + const [alpha, setAlpha] = React.useState(1); + + const currentValue = isControlled ? controlledValue : internalValue; + const error = propError; + + // Parse current color to RGB + const rgb = React.useMemo(() => { + const parsed = parseColor(currentValue); + return parsed || { r: 0, g: 0, b: 0, a: 1 }; + }, [currentValue]); + + // Parse current color to HSL + const hsl = React.useMemo(() => { + return rgbToHsl(rgb.r, rgb.g, rgb.b); + }, [rgb]); + + // Update internal state when controlled value changes + React.useEffect(() => { + if (isControlled && controlledValue) { + setHexInput(controlledValue); + const parsed = parseColor(controlledValue); + if (parsed) { + setAlpha(parsed.a); + } + } + }, [isControlled, controlledValue]); + + const updateColor = React.useCallback( + (newColor: string, newAlpha?: number) => { + const finalAlpha = newAlpha ?? alpha; + let colorValue = newColor; + + // Include alpha in output if enabled + if (showAlpha && finalAlpha < 1) { + const parsed = parseColor(newColor); + if (parsed) { + const alphaHex = Math.round(finalAlpha * 255) + .toString(16) + .padStart(2, '0'); + colorValue = `${newColor}${alphaHex}`; + } + } + + if (!isControlled) { + setInternalValue(colorValue); + setHexInput(newColor); + } + + onChange?.(colorValue); + }, + [alpha, showAlpha, isControlled, onChange], + ); + + const handleHexInputChange = (e: React.ChangeEvent) => { + let value = e.target.value; + + // Add # if not present + if (value && !value.startsWith('#')) { + value = '#' + value; + } + + setHexInput(value); + + // Validate and update if valid + if (/^#[a-f\d]{6}$/i.test(value)) { + updateColor(value); + } + }; + + const handleHexInputBlur = () => { + // On blur, revert to current value if invalid + if (!/^#[a-f\d]{6}$/i.test(hexInput)) { + setHexInput(currentValue.slice(0, 7)); + } + }; + + const handleRgbChange = (channel: 'r' | 'g' | 'b', value: number) => { + const newRgb = { ...rgb, [channel]: value }; + const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b); + updateColor(hex); + }; + + const handleHslChange = (channel: 'h' | 's' | 'l', value: number) => { + const newHsl = { ...hsl, [channel]: value }; + const newRgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l); + const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b); + updateColor(hex); + }; + + const handleAlphaChange = (value: number) => { + setAlpha(value); + updateColor(currentValue.slice(0, 7), value); + }; + + const handleSwatchClick = (swatchColor: string) => { + if (!disabled) { + updateColor(swatchColor); + } + }; + + const getDisplayValue = (): string => { + switch (format) { + case 'rgb': + return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; + case 'hsl': + return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`; + default: + return currentValue.slice(0, 7); + } + }; + + const id = `color-picker-${name}`; + const errorId = `${id}-error`; + const hintId = `${id}-hint`; + + return ( +
+ {/* Label */} + {label && ( + + )} + + {/* Hidden input for form submission */} + + + {/* Color Preview and Hex Input */} +
+ {/* Color Preview Box */} +
+ {/* Checkerboard for alpha preview */} + {showAlpha && alpha < 1 && ( +
+ )} +
+ + {/* Hex Input */} +
+ +
+
+ + {/* Color Swatches */} + {showSwatches && swatches.length > 0 && ( +
+ {swatches.map((swatchColor, index) => { + const isSelected = currentValue.toLowerCase().startsWith(swatchColor.toLowerCase()); + const isLight = isLightColor(swatchColor); + + return ( + + ); + })} +
+ )} + + {/* RGB Inputs */} + {showRgbInputs && ( +
+ {(['r', 'g', 'b'] as const).map((channel) => ( +
+ + handleRgbChange(channel, parseInt(e.target.value, 10) || 0)} + disabled={disabled} + className={` + w-full px-2 py-1.5 border rounded text-sm text-center + focus:outline-none focus:ring-2 focus:ring-blue-500 + ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} + border-gray-300 + `} + aria-label={`${channel.toUpperCase()} value`} + /> +
+ ))} +
+ )} + + {/* HSL Inputs */} + {showHslInputs && ( +
+
+ + handleHslChange('h', parseInt(e.target.value, 10) || 0)} + disabled={disabled} + className={` + w-full px-2 py-1.5 border rounded text-sm text-center + focus:outline-none focus:ring-2 focus:ring-blue-500 + ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} + border-gray-300 + `} + aria-label="Hue value" + /> +
+ {(['s', 'l'] as const).map((channel) => ( +
+ + handleHslChange(channel, parseInt(e.target.value, 10) || 0)} + disabled={disabled} + className={` + w-full px-2 py-1.5 border rounded text-sm text-center + focus:outline-none focus:ring-2 focus:ring-blue-500 + ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} + border-gray-300 + `} + aria-label={`${channel === 's' ? 'Saturation' : 'Lightness'} value`} + /> +
+ ))} +
+ )} + + {/* Alpha Slider */} + {showAlpha && ( +
+ +
+ handleAlphaChange(parseFloat(e.target.value))} + disabled={disabled} + className={` + flex-1 h-2 rounded-lg appearance-none cursor-pointer + ${disabled ? 'opacity-50 cursor-not-allowed' : ''} + `} + style={{ + background: `linear-gradient(to right, transparent, ${currentValue.slice(0, 7)})`, + }} + aria-label="Alpha/opacity value" + /> + + {Math.round(alpha * 100)}% + +
+
+ )} + + {/* Hint Text */} + {hint && !error && ( +

+ {hint} +

+ )} + + {/* Error Message */} + {error && ( + + )} +
+ ); + }, +); + +ColorPicker.displayName = 'ColorPicker'; + +export default ColorPicker; diff --git a/src/components/__tests__/ColorPicker.test.tsx b/src/components/__tests__/ColorPicker.test.tsx new file mode 100644 index 0000000..19af63b --- /dev/null +++ b/src/components/__tests__/ColorPicker.test.tsx @@ -0,0 +1,538 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + ColorPicker, + hexToRgb, + rgbToHex, + rgbToHsl, + hslToRgb, + parseColor, + isLightColor, + DEFAULT_SWATCHES, +} from '../ColorPicker'; + +describe('ColorPicker', () => { + describe('basic rendering', () => { + it('renders with name', () => { + render(); + const input = document.querySelector('input[name="color"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders color preview', () => { + render(); + const preview = screen.getByRole('img'); + expect(preview).toBeInTheDocument(); + }); + + it('renders hex input', () => { + render(); + const hexInput = screen.getByLabelText('Hex color value'); + expect(hexInput).toBeInTheDocument(); + }); + + it('renders default color', () => { + render(); + const hexInput = screen.getByLabelText('Hex color value'); + expect(hexInput).toHaveValue('#ff0000'); + }); + + it('renders with label', () => { + render(); + expect(screen.getByText('Pick a color')).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + }); + + describe('hex input', () => { + it('updates color on valid hex input', async () => { + const handleChange = vi.fn(); + render(); + + const hexInput = screen.getByLabelText('Hex color value'); + await userEvent.clear(hexInput); + await userEvent.type(hexInput, '#ff5500'); + + expect(handleChange).toHaveBeenCalledWith('#ff5500'); + }); + + it('adds hash if missing', async () => { + render(); + + const hexInput = screen.getByLabelText('Hex color value'); + await userEvent.clear(hexInput); + await userEvent.type(hexInput, 'ff5500'); + + expect(hexInput).toHaveValue('#ff5500'); + }); + + it('shows placeholder', () => { + render(); + const hexInput = screen.getByLabelText('Hex color value'); + expect(hexInput).toHaveAttribute('placeholder', '#123456'); + }); + + it('has max length of 7', () => { + render(); + const hexInput = screen.getByLabelText('Hex color value'); + expect(hexInput).toHaveAttribute('maxLength', '7'); + }); + + it('reverts invalid hex on blur', async () => { + render(); + + const hexInput = screen.getByLabelText('Hex color value'); + await userEvent.clear(hexInput); + await userEvent.type(hexInput, 'invalid'); + fireEvent.blur(hexInput); + + expect(hexInput).toHaveValue('#123456'); + }); + }); + + describe('color swatches', () => { + it('renders default swatches', () => { + render(); + const swatchList = screen.getByRole('listbox'); + expect(swatchList).toBeInTheDocument(); + }); + + it('renders custom swatches', () => { + const customSwatches = ['#ff0000', '#00ff00', '#0000ff']; + render(); + + const swatches = screen.getAllByRole('option'); + expect(swatches).toHaveLength(3); + }); + + it('selects color on swatch click', async () => { + const handleChange = vi.fn(); + render(); + + const swatch = screen.getByRole('option'); + await userEvent.click(swatch); + + expect(handleChange).toHaveBeenCalledWith('#ff0000'); + }); + + it('shows checkmark on selected swatch', async () => { + render(); + + // Find the swatch for red color + const selectedSwatch = screen.getByRole('option', { selected: true }); + expect(selectedSwatch).toBeInTheDocument(); + }); + + it('can hide swatches', () => { + render(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('disables swatches when picker is disabled', () => { + render(); + const swatch = screen.getByRole('option'); + expect(swatch).toBeDisabled(); + }); + }); + + describe('RGB inputs', () => { + it('renders RGB inputs by default', () => { + render(); + + expect(screen.getByLabelText('R value')).toBeInTheDocument(); + expect(screen.getByLabelText('G value')).toBeInTheDocument(); + expect(screen.getByLabelText('B value')).toBeInTheDocument(); + }); + + it('displays correct RGB values', () => { + render(); + + expect(screen.getByLabelText('R value')).toHaveValue(255); + expect(screen.getByLabelText('G value')).toHaveValue(128); + expect(screen.getByLabelText('B value')).toHaveValue(0); + }); + + it('updates color on R change', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const rInput = screen.getByLabelText('R value'); + await userEvent.clear(rInput); + await userEvent.type(rInput, '255'); + + expect(handleChange).toHaveBeenCalled(); + }); + + it('updates color on G change', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const gInput = screen.getByLabelText('G value'); + await userEvent.clear(gInput); + await userEvent.type(gInput, '255'); + + expect(handleChange).toHaveBeenCalled(); + }); + + it('updates color on B change', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const bInput = screen.getByLabelText('B value'); + await userEvent.clear(bInput); + await userEvent.type(bInput, '255'); + + expect(handleChange).toHaveBeenCalled(); + }); + + it('can hide RGB inputs', () => { + render(); + expect(screen.queryByLabelText('R value')).not.toBeInTheDocument(); + }); + }); + + describe('HSL inputs', () => { + it('does not show HSL inputs by default', () => { + render(); + expect(screen.queryByLabelText('Hue value')).not.toBeInTheDocument(); + }); + + it('renders HSL inputs when enabled', () => { + render(); + + expect(screen.getByLabelText('Hue value')).toBeInTheDocument(); + expect(screen.getByLabelText('Saturation value')).toBeInTheDocument(); + expect(screen.getByLabelText('Lightness value')).toBeInTheDocument(); + }); + + it('updates color on HSL change', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const hInput = screen.getByLabelText('Hue value'); + await userEvent.clear(hInput); + await userEvent.type(hInput, '120'); + + expect(handleChange).toHaveBeenCalled(); + }); + }); + + describe('alpha slider', () => { + it('does not show alpha by default', () => { + render(); + expect(screen.queryByLabelText('Alpha/opacity value')).not.toBeInTheDocument(); + }); + + it('renders alpha slider when enabled', () => { + render(); + expect(screen.getByLabelText('Alpha/opacity value')).toBeInTheDocument(); + }); + + it('updates alpha on slider change', async () => { + render(); + + const alphaSlider = screen.getByLabelText('Alpha/opacity value'); + fireEvent.change(alphaSlider, { target: { value: '0.5' } }); + + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('includes alpha in output when less than 1', async () => { + const handleChange = vi.fn(); + render(); + + const alphaSlider = screen.getByLabelText('Alpha/opacity value'); + fireEvent.change(alphaSlider, { target: { value: '0.5' } }); + + // Alpha value should be appended as hex + expect(handleChange).toHaveBeenCalledWith(expect.stringMatching(/^#ff0000[a-f0-9]{2}$/i)); + }); + }); + + describe('controlled vs uncontrolled', () => { + it('works as uncontrolled with defaultValue', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const swatch = screen.getByRole('option'); + await userEvent.click(swatch); + + const hexInput = screen.getByLabelText('Hex color value'); + expect(hexInput).toHaveValue('#000000'); + expect(handleChange).toHaveBeenCalledWith('#000000'); + }); + + it('works as controlled with value', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const swatch = screen.getByRole('option'); + await userEvent.click(swatch); + + // Value should not change (controlled) + expect(handleChange).toHaveBeenCalledWith('#00ff00'); + }); + }); + + describe('disabled state', () => { + it('disables hex input', () => { + render(); + const hexInput = screen.getByLabelText('Hex color value'); + expect(hexInput).toBeDisabled(); + }); + + it('disables RGB inputs', () => { + render(); + expect(screen.getByLabelText('R value')).toBeDisabled(); + expect(screen.getByLabelText('G value')).toBeDisabled(); + expect(screen.getByLabelText('B value')).toBeDisabled(); + }); + + it('disables alpha slider', () => { + render(); + expect(screen.getByLabelText('Alpha/opacity value')).toBeDisabled(); + }); + + it('disables swatch selection', async () => { + const handleChange = vi.fn(); + render(); + + const swatch = screen.getByRole('option'); + await userEvent.click(swatch); + + expect(handleChange).not.toHaveBeenCalled(); + }); + }); + + describe('hint and error', () => { + it('shows hint text', () => { + render(); + expect(screen.getByText('Choose your favorite color')).toBeInTheDocument(); + }); + + it('shows error message', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent('Invalid color'); + }); + + it('hides hint when error is present', () => { + render(); + expect(screen.queryByText('Choose color')).not.toBeInTheDocument(); + expect(screen.getByText('Invalid')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('has accessible color preview', () => { + render(); + const preview = screen.getByRole('img'); + expect(preview).toHaveAttribute('aria-label', expect.stringContaining('#ff0000')); + }); + + it('swatches have accessible labels', () => { + render(); + const swatch = screen.getByRole('option'); + expect(swatch).toHaveAttribute('aria-label', '#ff0000'); + }); + + it('links hex input to label', () => { + render(); + const label = screen.getByText('Color'); + const hexInput = screen.getByLabelText('Hex color value'); + + expect(label).toHaveAttribute('for', hexInput.id); + }); + + it('marks invalid when error exists', () => { + render(); + const hiddenInput = document.querySelector('input[name="color"]'); + expect(hiddenInput).toHaveAttribute('aria-invalid', 'true'); + }); + }); +}); + +// ============================================================================ +// Utility Function Tests +// ============================================================================ + +describe('hexToRgb', () => { + it('converts hex to RGB', () => { + expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 }); + expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 }); + expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('handles hex without hash', () => { + expect(hexToRgb('ff0000')).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('handles mixed case', () => { + expect(hexToRgb('#FF00ff')).toEqual({ r: 255, g: 0, b: 255 }); + }); + + it('returns null for invalid hex', () => { + expect(hexToRgb('invalid')).toBeNull(); + expect(hexToRgb('#gg0000')).toBeNull(); + }); +}); + +describe('rgbToHex', () => { + it('converts RGB to hex', () => { + expect(rgbToHex(255, 0, 0)).toBe('#ff0000'); + expect(rgbToHex(0, 255, 0)).toBe('#00ff00'); + expect(rgbToHex(0, 0, 255)).toBe('#0000ff'); + }); + + it('clamps values to valid range', () => { + expect(rgbToHex(300, -50, 128)).toBe('#ff0080'); + }); + + it('pads single digit hex values', () => { + expect(rgbToHex(0, 0, 0)).toBe('#000000'); + expect(rgbToHex(15, 15, 15)).toBe('#0f0f0f'); + }); +}); + +describe('rgbToHsl', () => { + it('converts red', () => { + const hsl = rgbToHsl(255, 0, 0); + expect(hsl.h).toBe(0); + expect(hsl.s).toBe(100); + expect(hsl.l).toBe(50); + }); + + it('converts green', () => { + const hsl = rgbToHsl(0, 255, 0); + expect(hsl.h).toBe(120); + expect(hsl.s).toBe(100); + expect(hsl.l).toBe(50); + }); + + it('converts blue', () => { + const hsl = rgbToHsl(0, 0, 255); + expect(hsl.h).toBe(240); + expect(hsl.s).toBe(100); + expect(hsl.l).toBe(50); + }); + + it('converts gray', () => { + const hsl = rgbToHsl(128, 128, 128); + expect(hsl.h).toBe(0); + expect(hsl.s).toBe(0); + expect(hsl.l).toBe(50); + }); +}); + +describe('hslToRgb', () => { + it('converts red', () => { + const rgb = hslToRgb(0, 100, 50); + expect(rgb).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('converts green', () => { + const rgb = hslToRgb(120, 100, 50); + expect(rgb).toEqual({ r: 0, g: 255, b: 0 }); + }); + + it('converts blue', () => { + const rgb = hslToRgb(240, 100, 50); + expect(rgb).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('converts gray', () => { + const rgb = hslToRgb(0, 0, 50); + expect(rgb.r).toBe(rgb.g); + expect(rgb.g).toBe(rgb.b); + }); +}); + +describe('parseColor', () => { + it('parses hex colors', () => { + expect(parseColor('#ff0000')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('parses short hex colors', () => { + expect(parseColor('#f00')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('parses hex with alpha', () => { + expect(parseColor('#ff000080')).toEqual({ r: 255, g: 0, b: 0, a: 128 / 255 }); + }); + + it('parses rgb colors', () => { + expect(parseColor('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('parses rgba colors', () => { + expect(parseColor('rgba(255, 0, 0, 0.5)')).toEqual({ r: 255, g: 0, b: 0, a: 0.5 }); + }); + + it('parses hsl colors', () => { + const result = parseColor('hsl(0, 100%, 50%)'); + expect(result?.r).toBe(255); + expect(result?.g).toBe(0); + expect(result?.b).toBe(0); + }); + + it('parses hsla colors', () => { + const result = parseColor('hsla(0, 100%, 50%, 0.5)'); + expect(result?.a).toBe(0.5); + }); + + it('returns null for invalid colors', () => { + expect(parseColor('invalid')).toBeNull(); + expect(parseColor('')).toBeNull(); + }); +}); + +describe('isLightColor', () => { + it('returns true for white', () => { + expect(isLightColor('#ffffff')).toBe(true); + }); + + it('returns false for black', () => { + expect(isLightColor('#000000')).toBe(false); + }); + + it('returns true for yellow', () => { + expect(isLightColor('#ffff00')).toBe(true); + }); + + it('returns false for dark blue', () => { + expect(isLightColor('#000080')).toBe(false); + }); + + it('returns true for invalid colors', () => { + expect(isLightColor('invalid')).toBe(true); + }); +}); + +describe('DEFAULT_SWATCHES', () => { + it('is an array of hex colors', () => { + expect(Array.isArray(DEFAULT_SWATCHES)).toBe(true); + expect(DEFAULT_SWATCHES.length).toBeGreaterThan(0); + expect(DEFAULT_SWATCHES.every((c) => /^#[a-f0-9]{6}$/i.test(c))).toBe(true); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index c0256c4..febc008 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export * from './NumberInput'; export * from './DatePicker'; export * from './PhoneInput'; export * from './FileInput'; +export * from './ColorPicker'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From b565391e9654301daf8d7e9999f9858b713108e0 Mon Sep 17 00:00:00 2001 From: omaima Date: Fri, 27 Feb 2026 15:10:28 +0000 Subject: [PATCH 12/51] feat: add Switch component with toggle states and customization --- src/components/Switch.tsx | 372 +++++++++++++++++++++++ src/components/__tests__/Switch.test.tsx | 352 +++++++++++++++++++++ src/components/index.ts | 1 + 3 files changed, 725 insertions(+) create mode 100644 src/components/Switch.tsx create mode 100644 src/components/__tests__/Switch.test.tsx diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx new file mode 100644 index 0000000..52c8d25 --- /dev/null +++ b/src/components/Switch.tsx @@ -0,0 +1,372 @@ +/** + * Switch Component + * + * A toggle switch component with customizable appearance and behavior. + */ + +import { forwardRef, useId, useState, useCallback } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +export type SwitchSize = 'sm' | 'md' | 'lg'; +export type SwitchColor = 'blue' | 'green' | 'red' | 'purple' | 'orange' | 'gray'; + +export interface SwitchProps extends Omit< + React.InputHTMLAttributes, + 'size' | 'type' | 'onChange' +> { + /** Input name for form submission */ + name: string; + /** Whether the switch is on (controlled) */ + checked?: boolean; + /** Initial checked state (uncontrolled) */ + defaultChecked?: boolean; + /** Callback when switch state changes */ + onChange?: (checked: boolean) => void; + /** Label text */ + label?: string; + /** Position of the label */ + labelPosition?: 'left' | 'right'; + /** Description text under the label */ + description?: string; + /** Text shown when switch is on */ + onLabel?: string; + /** Text shown when switch is off */ + offLabel?: string; + /** Size of the switch */ + size?: SwitchSize; + /** Color theme when on */ + color?: SwitchColor; + /** Whether the switch is disabled */ + disabled?: boolean; + /** Whether the switch is required */ + required?: boolean; + /** Error message */ + error?: string; + /** Additional class for the container */ + className?: string; + /** Show on/off icons inside the switch */ + showIcons?: boolean; +} + +// ============================================================================ +// Size configurations +// ============================================================================ + +const sizeStyles: Record< + SwitchSize, + { + track: string; + thumb: string; + thumbTranslate: string; + icon: string; + label: string; + } +> = { + sm: { + track: 'w-8 h-4', + thumb: 'w-3 h-3', + thumbTranslate: 'translate-x-4', + icon: 'w-2 h-2', + label: 'text-sm', + }, + md: { + track: 'w-11 h-6', + thumb: 'w-5 h-5', + thumbTranslate: 'translate-x-5', + icon: 'w-3 h-3', + label: 'text-base', + }, + lg: { + track: 'w-14 h-7', + thumb: 'w-6 h-6', + thumbTranslate: 'translate-x-7', + icon: 'w-4 h-4', + label: 'text-lg', + }, +}; + +// ============================================================================ +// Color configurations +// ============================================================================ + +const colorStyles: Record< + SwitchColor, + { + on: string; + focus: string; + } +> = { + blue: { + on: 'bg-blue-600', + focus: 'focus:ring-blue-500', + }, + green: { + on: 'bg-green-600', + focus: 'focus:ring-green-500', + }, + red: { + on: 'bg-red-600', + focus: 'focus:ring-red-500', + }, + purple: { + on: 'bg-purple-600', + focus: 'focus:ring-purple-500', + }, + orange: { + on: 'bg-orange-600', + focus: 'focus:ring-orange-500', + }, + gray: { + on: 'bg-gray-600', + focus: 'focus:ring-gray-500', + }, +}; + +// ============================================================================ +// Switch Component +// ============================================================================ + +export const Switch = forwardRef( + ( + { + name, + checked: controlledChecked, + defaultChecked = false, + onChange, + label, + labelPosition = 'right', + description, + onLabel, + offLabel, + size = 'md', + color = 'blue', + disabled = false, + required = false, + error, + className = '', + showIcons = false, + id: propId, + ...props + }, + ref, + ) => { + const generatedId = useId(); + const id = propId || generatedId; + const errorId = `${id}-error`; + const descriptionId = `${id}-description`; + + const isControlled = controlledChecked !== undefined; + const [internalChecked, setInternalChecked] = useState(defaultChecked); + const isChecked = isControlled ? controlledChecked : internalChecked; + + const sizeConfig = sizeStyles[size]; + const colorConfig = colorStyles[color]; + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newChecked = e.target.checked; + + if (!isControlled) { + setInternalChecked(newChecked); + } + + onChange?.(newChecked); + }, + [isControlled, onChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + + // Toggle on Space or Enter + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + const newChecked = !isChecked; + + if (!isControlled) { + setInternalChecked(newChecked); + } + + onChange?.(newChecked); + } + }, + [disabled, isChecked, isControlled, onChange], + ); + + const renderSwitch = () => ( +
+ {/* Hidden actual checkbox */} + + + {/* Track */} + + + {/* On/Off text labels inside track - alternative to icons */} + {(onLabel || offLabel) && !showIcons && ( + + )} +
+ ); + + const renderLabel = () => ( +
+ + {label} + {required && ( + + )} + + {description && ( + + {description} + + )} +
+ ); + + return ( +
+
+ {label && labelPosition === 'left' && renderLabel()} + {renderSwitch()} + {label && labelPosition === 'right' && renderLabel()} +
+ + {/* Error Message */} + {error && ( + + )} +
+ ); + }, +); + +Switch.displayName = 'Switch'; + +export default Switch; diff --git a/src/components/__tests__/Switch.test.tsx b/src/components/__tests__/Switch.test.tsx new file mode 100644 index 0000000..4e3e209 --- /dev/null +++ b/src/components/__tests__/Switch.test.tsx @@ -0,0 +1,352 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Switch } from '../Switch'; + +describe('Switch', () => { + describe('basic rendering', () => { + it('renders with name', () => { + render(); + const input = document.querySelector('input[name="toggle"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders as checkbox input', () => { + render(); + const input = document.querySelector('input[type="checkbox"]'); + expect(input).toBeInTheDocument(); + }); + + it('renders switch role', () => { + render(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('renders with label', () => { + render(); + expect(screen.getByText('Enable notifications')).toBeInTheDocument(); + }); + + it('shows required indicator', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('renders with description', () => { + render(); + expect(screen.getByText('Get email updates')).toBeInTheDocument(); + }); + }); + + describe('checked state', () => { + it('is unchecked by default', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + }); + + it('respects defaultChecked prop', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + + it('respects controlled checked prop', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + + it('toggles on click', async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + await userEvent.click(switchElement); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('toggles off when already on', async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + await userEvent.click(switchElement); + + expect(handleChange).toHaveBeenCalledWith(false); + }); + + it('updates internal state when uncontrolled', async () => { + render(); + + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(switchElement); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + }); + + describe('keyboard interaction', () => { + it('toggles on Space key', async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + switchElement.focus(); + fireEvent.keyDown(switchElement, { key: ' ' }); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('toggles on Enter key', () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + switchElement.focus(); + fireEvent.keyDown(switchElement, { key: 'Enter' }); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('is focusable', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('tabIndex', '0'); + }); + }); + + describe('label position', () => { + it('places label on right by default', () => { + render(); + + const container = screen.getByRole('switch').closest('div')?.parentElement; + const children = container?.children; + + // Switch should be before label + expect(children?.[0]).toContainElement(screen.getByRole('switch')); + }); + + it('places label on left when specified', () => { + render(); + + const label = screen.getByText('Toggle'); + expect(label).toBeInTheDocument(); + }); + }); + + describe('sizes', () => { + it('renders small size', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('w-8'); + }); + + it('renders medium size (default)', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('w-11'); + }); + + it('renders large size', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('w-14'); + }); + }); + + describe('colors', () => { + it('uses blue color by default', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('bg-blue-600'); + }); + + it('uses green color', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('bg-green-600'); + }); + + it('uses red color', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('bg-red-600'); + }); + + it('uses purple color', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('bg-purple-600'); + }); + + it('uses gray when off', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('bg-gray-300'); + }); + }); + + describe('on/off labels', () => { + it('shows on label when checked', () => { + render(); + expect(screen.getByText('ON')).toBeInTheDocument(); + }); + + it('shows off label when unchecked', () => { + render(); + expect(screen.getByText('OFF')).toBeInTheDocument(); + }); + }); + + describe('icons', () => { + it('shows icons when enabled', () => { + render(); + const switchElement = screen.getByRole('switch'); + const svgs = switchElement.querySelectorAll('svg'); + expect(svgs.length).toBe(2); // Check and X icons + }); + + it('hides icons by default', () => { + render(); + const switchElement = screen.getByRole('switch'); + const svgs = switchElement.querySelectorAll('svg'); + expect(svgs.length).toBe(0); + }); + }); + + describe('disabled state', () => { + it('disables the input', () => { + render(); + const input = document.querySelector('input'); + expect(input).toBeDisabled(); + }); + + it('applies disabled styling', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement.className).toContain('opacity-50'); + }); + + it('removes from tab order', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('tabIndex', '-1'); + }); + + it('does not toggle when clicked', async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + await userEvent.click(switchElement); + + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('does not toggle on keyboard', () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + fireEvent.keyDown(switchElement, { key: ' ' }); + + expect(handleChange).not.toHaveBeenCalled(); + }); + }); + + describe('error state', () => { + it('shows error message', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent('This field is required'); + }); + + it('marks input as invalid', () => { + render(); + const input = document.querySelector('input'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + }); + + describe('controlled vs uncontrolled', () => { + it('works as uncontrolled with defaultChecked', async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + await userEvent.click(switchElement); + + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('calls onChange for controlled component', async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole('switch'); + await userEvent.click(switchElement); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + }); + + describe('accessibility', () => { + it('has role switch', () => { + render(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('has aria-checked attribute', () => { + render(); + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + }); + + it('links to description', () => { + render(); + const input = document.querySelector('input'); + expect(input).toHaveAttribute('aria-describedby', expect.stringContaining('description')); + }); + + it('links to error', () => { + render(); + const input = document.querySelector('input'); + expect(input).toHaveAttribute('aria-describedby', expect.stringContaining('error')); + }); + + it('uses custom id when provided', () => { + render(); + const input = document.querySelector('#custom-switch'); + expect(input).toBeInTheDocument(); + }); + }); + + describe('form integration', () => { + it('submits value in form', async () => { + const handleSubmit = vi.fn((e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + handleSubmit.mock.calls[0][1] = formData.get('newsletter'); + }); + + render( +
+ + + , + ); + + const input = document.querySelector('input') as HTMLInputElement; + expect(input.checked).toBe(true); + }); + + it('applies required attribute', () => { + render(); + const input = document.querySelector('input'); + expect(input).toBeRequired(); + }); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index febc008..343b82f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export * from './DatePicker'; export * from './PhoneInput'; export * from './FileInput'; export * from './ColorPicker'; +export * from './Switch'; export * from './Textarea'; export * from './Select'; export * from './Checkbox'; From 5568524ae20ba77908167cf668f159b55b8493ac Mon Sep 17 00:00:00 2001 From: omaima Date: Tue, 3 Mar 2026 13:50:51 +0000 Subject: [PATCH 13/51] refactor: restrict public API to DynamicForm export only --- src/components/Form.tsx | 3 +++ src/index.ts | 27 +++++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/Form.tsx b/src/components/Form.tsx index ec0d6d4..744eeca 100644 --- a/src/components/Form.tsx +++ b/src/components/Form.tsx @@ -102,3 +102,6 @@ export const Form = forwardRef(FormInner) as // Set display name for debugging (Form as React.FC).displayName = 'Form'; + +// Default export for public API +export default Form; diff --git a/src/index.ts b/src/index.ts index 77b5c8e..4b6af96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,21 @@ -export * from './components'; -export * from './hooks'; -export * from './utils'; -export * from './validation'; -export * from './errors'; -export * from './adapters'; +// ── Primary public surface ────────────────────────────────────── +// TODO: Replace './components/Form' with './components/form/DynamicForm' after refactor +export { default as DynamicForm } from './components/Form'; + +// ── Types (consumers need for field config + step config) ─────── +// TODO: Export correct types after refactor +// export type { DynamicFormProps } from './components/form/DynamicForm'; +// export type { FieldConfig, FieldOption } from './models/FieldConfig'; +// export type { StepConfig } from './models/StepConfig'; +// export type { FormState, FieldState } from './models/FormState'; +// export type { ValidationRule } from './models/ValidationRule'; + +// ── Enum (consumers reference when building field configs) ─────── +// export { FieldType } from './core/types'; + +// ── Schema helpers (for building Zod schemas declaratively) ───── +// export { createFieldSchema, mergeSchemas } from './core/schema-helpers'; + +// ── Advanced extensibility (exposed, but not primary API) ──────── +// export { useFormKit } from './hooks/useFormKit'; +// export { useFormContext } from './hooks/useFormContext'; From 1d0f85712adfb68c9d9d48370c295d69d98a056e Mon Sep 17 00:00:00 2001 From: omaima Date: Tue, 3 Mar 2026 15:12:28 +0000 Subject: [PATCH 14/51] refactor: restructure codebase to CHM architecture with core/, models/, fields/, and layout/ folders --- src/components/context/FormKitContext.tsx | 70 ++++++ src/components/context/index.ts | 5 + src/components/fields/ArrayField.tsx | 167 +++++++++++++ src/components/fields/CheckboxField.tsx | 90 +++++++ src/components/fields/DateField.tsx | 89 +++++++ src/components/fields/Field.tsx | 96 +++++++ src/components/fields/FileField.tsx | 105 ++++++++ src/components/fields/RadioGroupField.tsx | 102 ++++++++ src/components/fields/SelectField.tsx | 93 +++++++ src/components/fields/SwitchField.tsx | 103 ++++++++ src/components/fields/TextField.tsx | 100 ++++++++ src/components/fields/TextareaField.tsx | 82 ++++++ src/components/fields/index.ts | 14 ++ src/components/form/DynamicForm.tsx | 269 ++++++++++++++++++++ src/components/form/DynamicFormStep.tsx | 46 ++++ src/components/form/FormStepper.tsx | 116 +++++++++ src/components/form/index.ts | 7 + src/components/layout/FieldError.tsx | 39 +++ src/components/layout/FieldGroup.tsx | 43 ++++ src/components/layout/FieldLabel.tsx | 61 +++++ src/components/layout/FormActions.tsx | 131 ++++++++++ src/components/layout/index.ts | 8 + src/core/conditional.ts | 78 ++++++ src/core/errors.ts | 59 +++++ src/core/index.ts | 34 +++ src/core/schema-helpers.ts | 117 +++++++++ src/core/types.ts | 98 ++++++++ src/core/validator.ts | 93 +++++++ src/hooks/useAsyncValidation.ts | 158 ++++++++++++ src/hooks/useFieldArray.ts | 178 +++++++++++++ src/hooks/useFormContext.ts | 29 +++ src/hooks/useFormKit.ts | 290 ++++++++++++++++++++++ src/hooks/useFormStep.ts | 160 ++++++++++++ src/index.ts | 29 ++- src/models/FieldConfig.ts | 96 +++++++ src/models/FormState.ts | 66 +++++ src/models/StepConfig.ts | 34 +++ src/models/ValidationRule.ts | 37 +++ src/models/index.ts | 12 + 39 files changed, 3392 insertions(+), 12 deletions(-) create mode 100644 src/components/context/FormKitContext.tsx create mode 100644 src/components/context/index.ts create mode 100644 src/components/fields/ArrayField.tsx create mode 100644 src/components/fields/CheckboxField.tsx create mode 100644 src/components/fields/DateField.tsx create mode 100644 src/components/fields/Field.tsx create mode 100644 src/components/fields/FileField.tsx create mode 100644 src/components/fields/RadioGroupField.tsx create mode 100644 src/components/fields/SelectField.tsx create mode 100644 src/components/fields/SwitchField.tsx create mode 100644 src/components/fields/TextField.tsx create mode 100644 src/components/fields/TextareaField.tsx create mode 100644 src/components/fields/index.ts create mode 100644 src/components/form/DynamicForm.tsx create mode 100644 src/components/form/DynamicFormStep.tsx create mode 100644 src/components/form/FormStepper.tsx create mode 100644 src/components/form/index.ts create mode 100644 src/components/layout/FieldError.tsx create mode 100644 src/components/layout/FieldGroup.tsx create mode 100644 src/components/layout/FieldLabel.tsx create mode 100644 src/components/layout/FormActions.tsx create mode 100644 src/components/layout/index.ts create mode 100644 src/core/conditional.ts create mode 100644 src/core/errors.ts create mode 100644 src/core/index.ts create mode 100644 src/core/schema-helpers.ts create mode 100644 src/core/types.ts create mode 100644 src/core/validator.ts create mode 100644 src/hooks/useAsyncValidation.ts create mode 100644 src/hooks/useFieldArray.ts create mode 100644 src/hooks/useFormContext.ts create mode 100644 src/hooks/useFormKit.ts create mode 100644 src/hooks/useFormStep.ts create mode 100644 src/models/FieldConfig.ts create mode 100644 src/models/FormState.ts create mode 100644 src/models/StepConfig.ts create mode 100644 src/models/ValidationRule.ts create mode 100644 src/models/index.ts diff --git a/src/components/context/FormKitContext.tsx b/src/components/context/FormKitContext.tsx new file mode 100644 index 0000000..20072a0 --- /dev/null +++ b/src/components/context/FormKitContext.tsx @@ -0,0 +1,70 @@ +/** + * FormKitContext - React context for form state propagation + * Internal only — not exported from public API + */ + +import { createContext, useContext, type ReactNode, type JSX } from 'react'; +import type { FormContextValue } from '../../models/FormState'; +import type { FormValues } from '../../core/types'; + +/** + * Form context (internal use only) + */ +export const FormKitContext = createContext(null); + +FormKitContext.displayName = 'FormKitContext'; + +/** + * Props for FormKitProvider + */ +type FormKitProviderProps = { + /** Form context value from useFormKit */ + value: FormContextValue; + /** Child components */ + children: ReactNode; +}; + +/** + * Provider component for form context + * Wraps DynamicForm children to provide access to form state + * + * @internal + */ +export function FormKitProvider({ + value, + children, +}: FormKitProviderProps): JSX.Element { + return ( + {children} + ); +} + +/** + * Hook to access form context from deep in the component tree + * Throws if used outside of FormKitProvider (i.e., outside DynamicForm) + * + * @returns Form context value + * @throws Error if used outside FormKitProvider + * + * @example + * ```tsx + * function CustomField() { + * const { getValue, setValue, getError } = useFormKitContext(); + * // ... + * } + * ``` + */ +export function useFormKitContext< + TValues extends FormValues = FormValues, +>(): FormContextValue { + const context = useContext(FormKitContext); + + if (!context) { + throw new Error( + 'useFormKitContext must be used within a DynamicForm. ' + + 'Make sure your component is rendered inside .', + ); + } + + return context as FormContextValue; +} diff --git a/src/components/context/index.ts b/src/components/context/index.ts new file mode 100644 index 0000000..116d36d --- /dev/null +++ b/src/components/context/index.ts @@ -0,0 +1,5 @@ +/** + * Context module exports + */ + +export { FormKitContext, FormKitProvider, useFormKitContext } from './FormKitContext'; diff --git a/src/components/fields/ArrayField.tsx b/src/components/fields/ArrayField.tsx new file mode 100644 index 0000000..c305a76 --- /dev/null +++ b/src/components/fields/ArrayField.tsx @@ -0,0 +1,167 @@ +/** + * ArrayField - Repeatable field group + */ + +import type { JSX } from 'react'; +import { useCallback, useMemo } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import type { FieldValue } from '../../core/types'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for ArrayField + */ +type Props = { + config: FieldConfig; +}; + +/** + * ArrayField component for repeatable field groups + * Follows WCAG 2.1 AA accessibility requirements + */ +export function ArrayField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + + // Ensure value is an array (wrapped in useMemo for stable reference) + const rows = useMemo(() => (Array.isArray(value) ? value : []), [value]); + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Constraints + const minRows = config.minRows ?? 0; + const maxRows = config.maxRows ?? Infinity; + const canAdd = rows.length < maxRows && !isDisabled; + const canRemove = rows.length > minRows && !isDisabled; + + // Labels + const addLabel = config.addLabel ?? 'Add'; + const removeLabel = config.removeLabel ?? 'Remove'; + + // Add a new row + const handleAdd = useCallback(() => { + if (!canAdd) return; + const newRow: Record = {}; + config.arrayFields?.forEach((field) => { + newRow[field.key] = ''; + }); + setValue(config.key, [...rows, newRow]); + }, [canAdd, rows, config.arrayFields, config.key, setValue]); + + // Remove a row + const handleRemove = useCallback( + (index: number) => { + if (!canRemove) return; + setValue( + config.key, + rows.filter((_, i) => i !== index), + ); + }, + [canRemove, rows, config.key, setValue], + ); + + // Update a field in a row + const handleFieldChange = useCallback( + (rowIndex: number, fieldKey: string, fieldValue: FieldValue) => { + const newRows = rows.map((row, i) => { + if (i !== rowIndex) return row; + return { ...(row as Record), [fieldKey]: fieldValue }; + }); + setValue(config.key, newRows); + }, + [rows, config.key, setValue], + ); + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + + {/* Rows */} +
+ {rows.map((row, rowIndex) => ( +
+ {/* Row fields */} +
+ {config.arrayFields?.map((fieldConfig) => { + const rowData = row as Record; + const fieldValue = rowData[fieldConfig.key] ?? ''; + const inputId = `${fieldId}-${rowIndex}-${fieldConfig.key}`; + + return ( +
+ + handleFieldChange(rowIndex, fieldConfig.key, e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ ); + })} +
+ + {/* Remove button */} + +
+ ))} +
+ + {/* Add button */} + + + {error && } +
+ ); +} + +export type { Props as ArrayFieldProps }; diff --git a/src/components/fields/CheckboxField.tsx b/src/components/fields/CheckboxField.tsx new file mode 100644 index 0000000..d359688 --- /dev/null +++ b/src/components/fields/CheckboxField.tsx @@ -0,0 +1,90 @@ +/** + * CheckboxField - Single checkbox input + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for CheckboxField + */ +type Props = { + config: FieldConfig; +}; + +/** + * CheckboxField component for boolean input + * Follows WCAG 2.1 AA accessibility requirements + */ +export function CheckboxField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + return ( +
+
+ setValue(config.key, e.target.checked)} + onBlur={() => setTouched(config.key, true)} + className={` + formkit-checkbox + h-4 w-4 + rounded + border-gray-300 + text-blue-600 + focus:ring-2 focus:ring-blue-500 + ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} + `} + /> + +
+ + {config.description && ( +

+ {config.description} +

+ )} + + {showError && } +
+ ); +} + +export type { Props as CheckboxFieldProps }; diff --git a/src/components/fields/DateField.tsx b/src/components/fields/DateField.tsx new file mode 100644 index 0000000..7aa6066 --- /dev/null +++ b/src/components/fields/DateField.tsx @@ -0,0 +1,89 @@ +/** + * DateField - Date input + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for DateField + */ +type Props = { + config: FieldConfig; +}; + +/** + * DateField component for date input + * Follows WCAG 2.1 AA accessibility requirements + */ +export function DateField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + // Format value for date input (expects YYYY-MM-DD) + const formatDateValue = (): string => { + if (!value) return ''; + if (value instanceof Date) { + return value.toISOString().split('T')[0]; + } + return String(value); + }; + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + + setValue(config.key, e.target.value)} + onBlur={() => setTouched(config.key, true)} + className={` + formkit-date-input + w-full px-3 py-2 + border rounded-md + focus:outline-none focus:ring-2 focus:ring-blue-500 + ${showError ? 'border-red-500' : 'border-gray-300'} + ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} + `} + /> + + {showError && } +
+ ); +} + +export type { Props as DateFieldProps }; diff --git a/src/components/fields/Field.tsx b/src/components/fields/Field.tsx new file mode 100644 index 0000000..7bc5d51 --- /dev/null +++ b/src/components/fields/Field.tsx @@ -0,0 +1,96 @@ +/** + * Field - Universal field router + * Reads field type and delegates to the appropriate field component + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { FieldType } from '../../core/types'; +import { isFieldVisible } from '../../core/conditional'; +import { useFormKitContext } from '../context/FormKitContext'; +import { TextField } from './TextField'; +import { TextareaField } from './TextareaField'; +import { SelectField } from './SelectField'; +import { CheckboxField } from './CheckboxField'; +import { RadioGroupField } from './RadioGroupField'; +import { SwitchField } from './SwitchField'; +import { DateField } from './DateField'; +import { FileField } from './FileField'; +import { ArrayField } from './ArrayField'; + +/** + * Props for Field component + */ +type Props = { + /** Field configuration */ + config: FieldConfig; +}; + +/** + * Universal field router — reads type from config and renders the appropriate field + * Handles conditional visibility via showWhen/hideWhen + * + * @internal — use DynamicForm, not Field directly + */ +export function Field({ config }: Props): JSX.Element | null { + const { getValues } = useFormKitContext(); + + // Check conditional visibility + const values = getValues(); + const visible = isFieldVisible(config.showWhen, config.hideWhen, values); + + if (!visible) { + return null; + } + + // Column span class + const colSpanClass = config.colSpan ? `col-span-${config.colSpan}` : ''; + const wrapperClass = `formkit-field ${colSpanClass} ${config.className ?? ''}`.trim(); + + // Route to appropriate field component based on type + const renderField = (): JSX.Element => { + switch (config.type) { + case FieldType.TEXT: + case FieldType.EMAIL: + case FieldType.PASSWORD: + case FieldType.NUMBER: + return ; + + case FieldType.TEXTAREA: + return ; + + case FieldType.SELECT: + return ; + + case FieldType.MULTI_SELECT: + // TODO: Implement MultiSelectField + return ; + + case FieldType.CHECKBOX: + return ; + + case FieldType.RADIO: + return ; + + case FieldType.SWITCH: + return ; + + case FieldType.DATE: + return ; + + case FieldType.FILE: + return ; + + case FieldType.ARRAY: + return ; + + default: + // Fallback to text field + return ; + } + }; + + return
{renderField()}
; +} + +export type { Props as FieldProps }; diff --git a/src/components/fields/FileField.tsx b/src/components/fields/FileField.tsx new file mode 100644 index 0000000..cb4fd66 --- /dev/null +++ b/src/components/fields/FileField.tsx @@ -0,0 +1,105 @@ +/** + * FileField - File upload input + */ + +import type { JSX, ChangeEvent } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for FileField + */ +type Props = { + config: FieldConfig; +}; + +/** + * FileField component for file uploads + * Follows WCAG 2.1 AA accessibility requirements + */ +export function FileField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + // Handle file selection + const handleChange = (e: ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + // Store single file or array based on multiple + setValue(config.key, files.length === 1 ? files[0] : Array.from(files)); + } + }; + + // Display selected file name(s) + const getFileName = (): string => { + if (!value) return ''; + if (value instanceof File) return value.name; + if (Array.isArray(value) && value.length > 0 && value[0] instanceof File) { + return (value as File[]).map((f) => f.name).join(', '); + } + return ''; + }; + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + +
+ setTouched(config.key, true)} + className={` + formkit-file-input + w-full px-3 py-2 + border rounded-md + file:mr-4 file:py-2 file:px-4 + file:rounded-md file:border-0 + file:text-sm file:font-medium + file:bg-blue-50 file:text-blue-700 + hover:file:bg-blue-100 + focus:outline-none focus:ring-2 focus:ring-blue-500 + ${showError ? 'border-red-500' : 'border-gray-300'} + ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} + `} + /> + {value &&

Selected: {getFileName()}

} +
+ + {showError && } +
+ ); +} + +export type { Props as FileFieldProps }; diff --git a/src/components/fields/RadioGroupField.tsx b/src/components/fields/RadioGroupField.tsx new file mode 100644 index 0000000..b8aa559 --- /dev/null +++ b/src/components/fields/RadioGroupField.tsx @@ -0,0 +1,102 @@ +/** + * RadioGroupField - Radio button group + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for RadioGroupField + */ +type Props = { + config: FieldConfig; +}; + +/** + * RadioGroupField component for selecting one option from a group + * Follows WCAG 2.1 AA accessibility requirements + */ +export function RadioGroupField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + +
+ {config.options?.map((option) => { + const optionId = `${fieldId}-${option.value}`; + const isChecked = value === option.value; + + return ( +
+ setValue(config.key, e.target.value)} + onBlur={() => setTouched(config.key, true)} + className={` + formkit-radio + h-4 w-4 + border-gray-300 + text-blue-600 + focus:ring-2 focus:ring-blue-500 + ${isDisabled || option.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} + `} + /> + +
+ ); + })} +
+ + {showError && } +
+ ); +} + +export type { Props as RadioGroupFieldProps }; diff --git a/src/components/fields/SelectField.tsx b/src/components/fields/SelectField.tsx new file mode 100644 index 0000000..f14e53a --- /dev/null +++ b/src/components/fields/SelectField.tsx @@ -0,0 +1,93 @@ +/** + * SelectField - Dropdown select input + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for SelectField + */ +type Props = { + config: FieldConfig; +}; + +/** + * SelectField component for dropdown selection + * Follows WCAG 2.1 AA accessibility requirements + */ +export function SelectField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + + + + {showError && } +
+ ); +} + +export type { Props as SelectFieldProps }; diff --git a/src/components/fields/SwitchField.tsx b/src/components/fields/SwitchField.tsx new file mode 100644 index 0000000..f3b6743 --- /dev/null +++ b/src/components/fields/SwitchField.tsx @@ -0,0 +1,103 @@ +/** + * SwitchField - Toggle switch input + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for SwitchField + */ +type Props = { + config: FieldConfig; +}; + +/** + * SwitchField component for boolean toggle + * Follows WCAG 2.1 AA accessibility requirements + */ +export function SwitchField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + const isChecked = Boolean(value); + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + return ( +
+
+ + +
+ + {config.description && ( +

+ {config.description} +

+ )} + + {showError && } +
+ ); +} + +export type { Props as SwitchFieldProps }; diff --git a/src/components/fields/TextField.tsx b/src/components/fields/TextField.tsx new file mode 100644 index 0000000..c310c5e --- /dev/null +++ b/src/components/fields/TextField.tsx @@ -0,0 +1,100 @@ +/** + * TextField - Handles text, email, password, and number inputs + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { FieldType } from '../../core/types'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for TextField + */ +type Props = { + config: FieldConfig; +}; + +/** + * TextField component for text, email, password, and number inputs + * Follows WCAG 2.1 AA accessibility requirements + */ +export function TextField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Map FieldType to input type + const inputType = (): string => { + switch (config.type) { + case FieldType.EMAIL: + return 'email'; + case FieldType.PASSWORD: + return 'password'; + case FieldType.NUMBER: + return 'number'; + default: + return 'text'; + } + }; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + + { + const newValue = + config.type === FieldType.NUMBER ? Number(e.target.value) : e.target.value; + setValue(config.key, newValue); + }} + onBlur={() => setTouched(config.key, true)} + className={` + formkit-input + w-full px-3 py-2 + border rounded-md + focus:outline-none focus:ring-2 focus:ring-blue-500 + ${showError ? 'border-red-500' : 'border-gray-300'} + ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} + `} + /> + + {showError && } +
+ ); +} + +export type { Props as TextFieldProps }; diff --git a/src/components/fields/TextareaField.tsx b/src/components/fields/TextareaField.tsx new file mode 100644 index 0000000..f82db56 --- /dev/null +++ b/src/components/fields/TextareaField.tsx @@ -0,0 +1,82 @@ +/** + * TextareaField - Multiline text input + */ + +import type { JSX } from 'react'; +import type { FieldConfig } from '../../models/FieldConfig'; +import { useFormKitContext } from '../context/FormKitContext'; +import { FieldLabel } from '../layout/FieldLabel'; +import { FieldError } from '../layout/FieldError'; + +/** + * Props for TextareaField + */ +type Props = { + config: FieldConfig; +}; + +/** + * TextareaField component for multiline text input + * Follows WCAG 2.1 AA accessibility requirements + */ +export function TextareaField({ config }: Props): JSX.Element { + const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext(); + + const fieldId = `field-${config.key}`; + const errorId = `${fieldId}-error`; + const descId = `${fieldId}-desc`; + + const value = getValue(config.key); + const error = getError(config.key); + const touched = getTouched(config.key); + const showError = touched && !!error; + + // Compute disabled state + const isDisabled = + typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled; + + // Build aria-describedby + const describedBy = + [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') || + undefined; + + return ( +
+ + + {config.description && ( +

+ {config.description} +

+ )} + +