diff --git a/.changeset/improved-field-styles.md b/.changeset/improved-field-styles.md new file mode 100644 index 0000000..5185a62 --- /dev/null +++ b/.changeset/improved-field-styles.md @@ -0,0 +1,14 @@ +--- +'@ciscode/ui-form-kit': patch +--- + +Improved field component styles with transitions, hover states, and success states + +- Added transition-all duration-150 for smooth animations +- Added hover:border-gray-400 for hover states on inputs +- Added green success state (border-green-500) when field is valid +- Improved responsive padding (px-3 py-2 sm:px-4 sm:py-2.5) +- Better disabled states with hover:border-gray-300 +- Improved checkbox, radio, and switch sizing +- Better file input button styling with blue background +- Added aria-live=polite to error messages diff --git a/.changeset/instructions-refactor.md b/.changeset/instructions-refactor.md new file mode 100644 index 0000000..c57b45f --- /dev/null +++ b/.changeset/instructions-refactor.md @@ -0,0 +1,33 @@ +--- +'@ciscode/ui-form-kit': minor +--- + +Refactor codebase to align with copilot-instructions.md guidelines + +**Architecture Changes:** + +- Restructured to Component-Hook-Model (CHM) architecture +- Created `src/core/` for framework-free pure functions (validator, conditional, schema-helpers) +- Created `src/models/` for TypeScript contracts with zero runtime logic +- Restricted public API exports in `src/index.ts` + +**Accessibility Improvements:** + +- Fixed RadioGroupField to use proper `role="radiogroup"` with `aria-labelledby` +- Fixed SwitchField to use proper `role="switch"` with keyboard support (Enter/Space) + +**Bug Fixes:** + +- Fixed `handleSubmit` to mark fields with errors as touched on validation failure +- Fixed `useAsyncValidation` to properly handle DOMException AbortError + +**Testing:** + +- Added comprehensive test suite with 192 tests +- Achieved 80%+ coverage: 94.64% statements, 83.77% branches, 83.33% functions +- Coverage tests for core/, hooks/, and components/ + +**Documentation:** + +- Enhanced JSDoc for FieldType enum values +- Enhanced JSDoc for ConditionalOperator types diff --git a/package-lock.json b/package-lock.json index 330c6b6..368ed3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitest/coverage-v8": "^2.1.8", + "@vitest/coverage-v8": "^2.1.9", "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -137,6 +137,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -756,6 +757,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -796,6 +798,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2799,8 +2802,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -2829,6 +2831,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2840,6 +2843,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2889,6 +2893,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -3266,6 +3271,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3688,6 +3694,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4255,8 +4262,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4539,6 +4545,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4603,6 +4610,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6363,6 +6371,7 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -6976,7 +6985,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7678,6 +7686,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7769,7 +7778,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7785,7 +7793,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7798,8 +7805,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -8257,8 +8263,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -8924,9 +8929,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { @@ -8966,9 +8971,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9109,6 +9114,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9385,6 +9391,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9510,6 +9517,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -10450,6 +10458,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index dc09214..c1deb2b 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@vitest/coverage-v8": "^2.1.9", "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", diff --git a/src/adapters/__tests__/react-hook-form.test.ts b/src/adapters/__tests__/react-hook-form.test.ts deleted file mode 100644 index 64ad34b..0000000 --- a/src/adapters/__tests__/react-hook-form.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * Tests for React Hook Form adapter - */ - -import { describe, it, expect } from 'vitest'; -import { - getFormKitProps, - getInputProps, - getTextareaProps, - getSelectProps, - getCheckboxProps, - getRadioGroupProps, - getErrorMessage, - isTouched, - isDirty, - hasError, -} from '../react-hook-form'; - -// Mock React Hook Form controller -const createMockController = ( - overrides: { - field?: Record; - fieldState?: Record; - } = {}, -) => ({ - field: { - onChange: () => {}, - onBlur: () => {}, - value: '', - name: 'testField', - ref: { current: null }, - ...(overrides.field || {}), - }, - fieldState: { - invalid: false, - isTouched: false, - isDirty: false, - error: undefined, - ...(overrides.fieldState || {}), - }, -}); - -describe('React Hook Form Adapter', () => { - describe('getFormKitProps', () => { - it('extracts basic field props', () => { - const controller = createMockController(); - const props = getFormKitProps(controller); - - expect(props.name).toBe('testField'); - expect(props.value).toBe(''); - expect(props.isTouched).toBe(false); - expect(props.isDirty).toBe(false); - expect(props.hasError).toBe(false); - expect(props.error).toBe(null); - }); - - it('includes error message when present', () => { - const controller = createMockController({ - fieldState: { - invalid: true, - error: { message: 'Field is required' }, - }, - }); - const props = getFormKitProps(controller); - - expect(props.error).toBe('Field is required'); - expect(props.hasError).toBe(true); - }); - - it('includes touched state', () => { - const controller = createMockController({ - fieldState: { isTouched: true }, - }); - const props = getFormKitProps(controller); - - expect(props.isTouched).toBe(true); - }); - - it('includes dirty state', () => { - const controller = createMockController({ - fieldState: { isDirty: true }, - }); - const props = getFormKitProps(controller); - - expect(props.isDirty).toBe(true); - }); - - it('throws error for invalid controller', () => { - expect(() => { - getFormKitProps({}); - }).toThrow('Invalid React Hook Form controller provided'); - }); - - it('throws error for null controller', () => { - expect(() => { - getFormKitProps(null); - }).toThrow('Invalid React Hook Form controller provided'); - }); - }); - - describe('getInputProps', () => { - it('extracts input-specific props', () => { - const controller = createMockController({ - field: { value: 'test value' }, - }); - const props = getInputProps(controller); - - expect(props.name).toBe('testField'); - expect(props.defaultValue).toBe('test value'); - expect(typeof props.onChange).toBe('function'); - expect(typeof props.onBlur).toBe('function'); - }); - - it('handles text input change', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getInputProps(controller); - - const event = { - target: { type: 'text', value: 'new value' }, - } as React.ChangeEvent; - props.onChange(event); - - expect(capturedValue).toBe('new value'); - }); - - it('handles number input change', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getInputProps(controller); - - const event = { - target: { type: 'number', value: '42', valueAsNumber: 42 }, - } as React.ChangeEvent; - props.onChange(event); - - expect(capturedValue).toBe(42); - }); - }); - - describe('getTextareaProps', () => { - it('extracts textarea-specific props', () => { - const controller = createMockController({ - field: { value: 'textarea content' }, - }); - const props = getTextareaProps(controller); - - expect(props.name).toBe('testField'); - expect(props.defaultValue).toBe('textarea content'); - expect(typeof props.onChange).toBe('function'); - }); - - it('handles textarea change', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getTextareaProps(controller); - - const event = { - target: { value: 'new content' }, - } as React.ChangeEvent; - props.onChange(event); - - expect(capturedValue).toBe('new content'); - }); - }); - - describe('getSelectProps', () => { - it('extracts select-specific props', () => { - const controller = createMockController({ - field: { value: 'option1' }, - }); - const props = getSelectProps(controller); - - expect(props.name).toBe('testField'); - expect(props.defaultValue).toBe('option1'); - expect(typeof props.onChange).toBe('function'); - }); - - it('handles select change', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getSelectProps(controller); - - props.onChange('option2'); - - expect(capturedValue).toBe('option2'); - }); - - it('handles multiple select', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - value: ['option1', 'option2'], - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getSelectProps(controller); - - expect(props.defaultValue).toEqual(['option1', 'option2']); - props.onChange(['option1', 'option3']); - expect(capturedValue).toEqual(['option1', 'option3']); - }); - }); - - describe('getCheckboxProps', () => { - it('extracts checkbox-specific props', () => { - const controller = createMockController({ - field: { value: true }, - }); - const props = getCheckboxProps(controller); - - expect(props.name).toBe('testField'); - expect(props.defaultChecked).toBe(true); - expect(typeof props.onChange).toBe('function'); - }); - - it('handles checkbox change', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getCheckboxProps(controller); - - props.onChange(true); - - expect(capturedValue).toBe(true); - }); - }); - - describe('getRadioGroupProps', () => { - it('extracts radio group-specific props', () => { - const controller = createMockController({ - field: { value: 'option1' }, - }); - const props = getRadioGroupProps(controller); - - expect(props.name).toBe('testField'); - expect(props.defaultValue).toBe('option1'); - expect(typeof props.onChange).toBe('function'); - }); - - it('handles radio change', () => { - let capturedValue: unknown; - const controller = createMockController({ - field: { - onChange: (value: unknown) => { - capturedValue = value; - }, - }, - }); - const props = getRadioGroupProps(controller); - - props.onChange('option2'); - - expect(capturedValue).toBe('option2'); - }); - }); - - describe('getErrorMessage', () => { - it('returns error message when present', () => { - const controller = createMockController({ - fieldState: { - error: { message: 'Error message' }, - }, - }); - - expect(getErrorMessage(controller)).toBe('Error message'); - }); - - it('returns null when no error', () => { - const controller = createMockController(); - expect(getErrorMessage(controller)).toBe(null); - }); - - it('returns null for invalid controller', () => { - expect(getErrorMessage({})).toBe(null); - }); - }); - - describe('isTouched', () => { - it('returns true when field is touched', () => { - const controller = createMockController({ - fieldState: { isTouched: true }, - }); - - expect(isTouched(controller)).toBe(true); - }); - - it('returns false when field is not touched', () => { - const controller = createMockController(); - expect(isTouched(controller)).toBe(false); - }); - - it('returns false for invalid controller', () => { - expect(isTouched({})).toBe(false); - }); - }); - - describe('isDirty', () => { - it('returns true when field is dirty', () => { - const controller = createMockController({ - fieldState: { isDirty: true }, - }); - - expect(isDirty(controller)).toBe(true); - }); - - it('returns false when field is not dirty', () => { - const controller = createMockController(); - expect(isDirty(controller)).toBe(false); - }); - - it('returns false for invalid controller', () => { - expect(isDirty({})).toBe(false); - }); - }); - - describe('hasError', () => { - it('returns true when field is invalid', () => { - const controller = createMockController({ - fieldState: { invalid: true }, - }); - - expect(hasError(controller)).toBe(true); - }); - - it('returns false when field is valid', () => { - const controller = createMockController(); - expect(hasError(controller)).toBe(false); - }); - - it('returns false for invalid controller', () => { - expect(hasError({})).toBe(false); - }); - }); - - describe('integration scenarios', () => { - it('handles complete field lifecycle', () => { - let currentValue = ''; - const controller = createMockController({ - field: { - value: currentValue, - onChange: (value: unknown) => { - currentValue = value as string; - }, - }, - fieldState: { - isTouched: false, - isDirty: false, - }, - }); - - const props = getInputProps(controller); - expect(props.defaultValue).toBe(''); - - // Simulate user input - const event = { - target: { type: 'text', value: 'new value' }, - } as React.ChangeEvent; - props.onChange(event); - - expect(currentValue).toBe('new value'); - }); - - it('handles error state changes', () => { - const withoutError = createMockController(); - expect(hasError(withoutError)).toBe(false); - expect(getErrorMessage(withoutError)).toBe(null); - - const withError = createMockController({ - fieldState: { - invalid: true, - error: { message: 'Required field' }, - }, - }); - expect(hasError(withError)).toBe(true); - expect(getErrorMessage(withError)).toBe('Required field'); - }); - - it('handles different value types', () => { - // String - const stringController = createMockController({ - field: { value: 'text' }, - }); - expect(getFormKitProps(stringController).value).toBe('text'); - - // Number - const numberController = createMockController({ - field: { value: 42 }, - }); - expect(getFormKitProps(numberController).value).toBe(42); - - // Boolean - const boolController = createMockController({ - field: { value: true }, - }); - expect(getFormKitProps(boolController).value).toBe(true); - - // Array - const arrayController = createMockController({ - field: { value: ['a', 'b'] }, - }); - expect(getFormKitProps(arrayController).value).toEqual(['a', 'b']); - }); - }); - - describe('edge cases', () => { - it('handles undefined error object', () => { - const controller = createMockController({ - fieldState: { - error: undefined, - }, - }); - - expect(getErrorMessage(controller)).toBe(null); - }); - - it('handles error without message', () => { - const controller = createMockController({ - fieldState: { - error: {}, - }, - }); - - expect(getErrorMessage(controller)).toBe(null); - }); - - it('handles empty string value', () => { - const controller = createMockController({ - field: { value: '' }, - }); - - expect(getFormKitProps(controller).value).toBe(''); - }); - - it('handles null value', () => { - const controller = createMockController({ - field: { value: null }, - }); - - expect(getFormKitProps(controller).value).toBe(null); - }); - - it('handles all states simultaneously', () => { - const controller = createMockController({ - field: { value: 'test', name: 'complexField' }, - fieldState: { - invalid: true, - isTouched: true, - isDirty: true, - error: { message: 'Complex error' }, - }, - }); - - expect(isTouched(controller)).toBe(true); - expect(isDirty(controller)).toBe(true); - expect(hasError(controller)).toBe(true); - expect(getErrorMessage(controller)).toBe('Complex error'); - }); - }); -}); diff --git a/src/adapters/__tests__/zod.test.ts b/src/adapters/__tests__/zod.test.ts deleted file mode 100644 index e679fa9..0000000 --- a/src/adapters/__tests__/zod.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Tests for Zod adapter - */ - -import { describe, it, expect } from 'vitest'; -import { zodValidator, zodAsyncValidator, isZodAvailable } from '../zod'; - -// Mock Zod schema for testing -const createMockZodSchema = (shouldSucceed: boolean, errorMessage = 'Validation failed') => ({ - safeParse: (value: unknown) => { - if (shouldSucceed) { - return { success: true as const, data: value }; - } - return { - success: false as const, - error: { - issues: [{ message: errorMessage }], - }, - }; - }, - safeParseAsync: async (value: unknown) => { - if (shouldSucceed) { - return { success: true as const, data: value }; - } - return { - success: false as const, - error: { - issues: [{ message: errorMessage }], - }, - }; - }, - _def: { typeName: 'ZodString' }, -}); - -describe('Zod Adapter', () => { - describe('zodValidator', () => { - it('returns null for valid value', () => { - const schema = createMockZodSchema(true); - const validator = zodValidator(schema); - const result = validator('valid value'); - expect(result).toBe(null); - }); - - it('returns error message for invalid value', () => { - const schema = createMockZodSchema(false, 'Invalid email format'); - const validator = zodValidator(schema); - const result = validator('invalid'); - expect(result).toBe('Invalid email format'); - }); - - it('uses custom error message when provided', () => { - const schema = createMockZodSchema(false, 'Zod error'); - const validator = zodValidator(schema, 'Custom error message'); - const result = validator('invalid'); - expect(result).toBe('Custom error message'); - }); - - it('returns default message when no issues in error', () => { - const schema = { - safeParse: () => ({ - success: false as const, - error: { issues: [] }, - }), - safeParseAsync: async () => ({ - success: false as const, - error: { issues: [] }, - }), - _def: { typeName: 'ZodString' }, - }; - const validator = zodValidator(schema); - const result = validator('value'); - expect(result).toBe('Validation failed'); - }); - - it('throws error for invalid schema', () => { - expect(() => { - zodValidator({}); - }).toThrow('Invalid Zod schema provided'); - }); - - it('throws error when schema is null', () => { - expect(() => { - zodValidator(null); - }).toThrow('Invalid Zod schema provided'); - }); - - it('throws error when schema is undefined', () => { - expect(() => { - zodValidator(undefined); - }).toThrow('Invalid Zod schema provided'); - }); - - it('validates different value types', () => { - const schema = createMockZodSchema(true); - const validator = zodValidator(schema); - - expect(validator('string')).toBe(null); - expect(validator(123)).toBe(null); - expect(validator(true)).toBe(null); - expect(validator(null)).toBe(null); - expect(validator(['array'])).toBe(null); - }); - }); - - describe('zodAsyncValidator', () => { - it('returns null for valid value', async () => { - const schema = createMockZodSchema(true); - const validator = zodAsyncValidator(schema); - const result = await validator('valid value'); - expect(result).toBe(null); - }); - - it('returns error message for invalid value', async () => { - const schema = createMockZodSchema(false, 'Async validation error'); - const validator = zodAsyncValidator(schema); - const result = await validator('invalid'); - expect(result).toBe('Async validation error'); - }); - - it('uses custom error message when provided', async () => { - const schema = createMockZodSchema(false, 'Zod async error'); - const validator = zodAsyncValidator(schema, 'Custom async error'); - const result = await validator('invalid'); - expect(result).toBe('Custom async error'); - }); - - it('returns default message when no issues in error', async () => { - const schema = { - safeParse: () => ({ - success: false as const, - error: { issues: [] }, - }), - safeParseAsync: async () => ({ - success: false as const, - error: { issues: [] }, - }), - _def: { typeName: 'ZodString' }, - }; - const validator = zodAsyncValidator(schema); - const result = await validator('value'); - expect(result).toBe('Validation failed'); - }); - - it('throws error for invalid schema', () => { - expect(() => { - zodAsyncValidator({}); - }).toThrow('Invalid Zod schema provided'); - }); - - it('validates different value types asynchronously', async () => { - const schema = createMockZodSchema(true); - const validator = zodAsyncValidator(schema); - - expect(await validator('string')).toBe(null); - expect(await validator(123)).toBe(null); - expect(await validator(true)).toBe(null); - expect(await validator(null)).toBe(null); - expect(await validator(['array'])).toBe(null); - }); - - it('handles async validation with delay', async () => { - const schema = { - safeParse: () => ({ success: true as const, data: 'value' }), - safeParseAsync: async (value: unknown) => { - // Simulate async delay - await new Promise((resolve) => setTimeout(resolve, 10)); - return { success: true as const, data: value }; - }, - _def: { typeName: 'ZodString' }, - }; - const validator = zodAsyncValidator(schema); - const startTime = Date.now(); - const result = await validator('test'); - const endTime = Date.now(); - - expect(result).toBe(null); - expect(endTime - startTime).toBeGreaterThanOrEqual(10); - }); - }); - - describe('isZodAvailable', () => { - it('returns true when Zod functionality is available', () => { - expect(isZodAvailable()).toBe(true); - }); - }); - - describe('integration scenarios', () => { - it('handles complex schema validation', () => { - const schema = createMockZodSchema(false, 'Must be a valid email address'); - const validator = zodValidator(schema); - const result = validator('not-an-email'); - expect(result).toBe('Must be a valid email address'); - }); - - it('handles multiple validators with Zod', () => { - const emailSchema = createMockZodSchema(false, 'Invalid email'); - const lengthSchema = createMockZodSchema(false, 'Too short'); - - const emailValidator = zodValidator(emailSchema); - const lengthValidator = zodValidator(lengthSchema); - - expect(emailValidator('invalid')).toBe('Invalid email'); - expect(lengthValidator('ab')).toBe('Too short'); - }); - - it('works with numeric values', () => { - const numberSchema = createMockZodSchema(true); - const validator = zodValidator(numberSchema); - expect(validator(42)).toBe(null); - }); - - it('works with boolean values', () => { - const booleanSchema = createMockZodSchema(true); - const validator = zodValidator(booleanSchema); - expect(validator(true)).toBe(null); - expect(validator(false)).toBe(null); - }); - - it('works with array values', () => { - const arraySchema = createMockZodSchema(true); - const validator = zodValidator(arraySchema); - expect(validator(['item1', 'item2'])).toBe(null); - }); - - it('async validator handles rejection gracefully', async () => { - const schema = createMockZodSchema(false, 'Async error'); - const validator = zodAsyncValidator(schema); - const result = await validator('failing value'); - expect(result).toBe('Async error'); - }); - - it('can override Zod error with custom message', () => { - const schema = createMockZodSchema(false, 'Expected number, received string'); - const validator = zodValidator(schema, 'Please enter a number'); - const result = validator('text'); - expect(result).toBe('Please enter a number'); - }); - - it('async validator can override Zod error with custom message', async () => { - const schema = createMockZodSchema(false, 'Expected number, received string'); - const validator = zodAsyncValidator(schema, 'Please enter a valid number'); - const result = await validator('text'); - expect(result).toBe('Please enter a valid number'); - }); - }); - - describe('edge cases', () => { - it('handles empty string validation', () => { - const schema = createMockZodSchema(false, 'String cannot be empty'); - const validator = zodValidator(schema); - expect(validator('')).toBe('String cannot be empty'); - }); - - it('handles null value', () => { - const schema = createMockZodSchema(true); - const validator = zodValidator(schema); - expect(validator(null)).toBe(null); - }); - - it('handles undefined value', () => { - const schema = createMockZodSchema(true); - const validator = zodValidator(schema); - expect(validator(undefined)).toBe(null); - }); - - it('handles empty array', () => { - const schema = createMockZodSchema(false, 'Array cannot be empty'); - const validator = zodValidator(schema); - expect(validator([])).toBe('Array cannot be empty'); - }); - }); -}); diff --git a/src/adapters/index.ts b/src/adapters/index.ts deleted file mode 100644 index 0af652c..0000000 --- a/src/adapters/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './zod'; -export * from './react-hook-form'; diff --git a/src/adapters/react-hook-form.ts b/src/adapters/react-hook-form.ts deleted file mode 100644 index 8173bc7..0000000 --- a/src/adapters/react-hook-form.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * React Hook Form adapter for FormKit UI - * Provides hooks and utilities to integrate FormKit components with React Hook Form - */ - -import type { FieldValue } from '../utils/types'; - -/** - * Type guard to check if React Hook Form is available - */ -function isReactHookFormController(controller: unknown): controller is { - field: { - onChange: (value: unknown) => void; - onBlur: () => void; - value: unknown; - name: string; - ref: React.Ref; - }; - fieldState: { - invalid: boolean; - isTouched: boolean; - isDirty: boolean; - error?: { message?: string }; - }; -} { - return ( - typeof controller === 'object' && - controller !== null && - 'field' in controller && - 'fieldState' in controller - ); -} - -/** - * Convert React Hook Form controller props to FormKit field props - * @param controller - React Hook Form controller object from useController - * @returns Props object for FormKit components - */ -export function getFormKitProps(controller: unknown) { - if (!isReactHookFormController(controller)) { - throw new Error( - 'Invalid React Hook Form controller provided. Make sure you are passing the result from useController.', - ); - } - - const { field, fieldState } = controller; - - return { - name: field.name, - value: field.value as FieldValue, - onChange: field.onChange, - onBlur: field.onBlur, - ref: field.ref, - error: fieldState.error?.message ?? null, - isTouched: fieldState.isTouched, - isDirty: fieldState.isDirty, - hasError: fieldState.invalid, - }; -} - -/** - * Helper to extract field props for Input components - * @param controller - React Hook Form controller object - * @returns Props for Input component - */ -export function getInputProps(controller: unknown) { - const props = getFormKitProps(controller); - return { - name: props.name, - defaultValue: props.value as string | number, - onChange: (e: React.ChangeEvent) => { - const value = e.target.type === 'number' ? e.target.valueAsNumber : e.target.value; - props.onChange(value); - }, - onBlur: props.onBlur, - ref: props.ref as React.Ref, - }; -} - -/** - * Helper to extract field props for Textarea components - * @param controller - React Hook Form controller object - * @returns Props for Textarea component - */ -export function getTextareaProps(controller: unknown) { - const props = getFormKitProps(controller); - return { - name: props.name, - defaultValue: props.value as string, - onChange: (e: React.ChangeEvent) => { - props.onChange(e.target.value); - }, - onBlur: props.onBlur, - ref: props.ref as React.Ref, - }; -} - -/** - * Helper to extract field props for Select components - * @param controller - React Hook Form controller object - * @returns Props for Select component - */ -export function getSelectProps(controller: unknown) { - const props = getFormKitProps(controller); - return { - name: props.name, - defaultValue: props.value as string | number | string[] | number[], - onChange: (value: string | number | string[] | number[]) => { - props.onChange(value); - }, - onBlur: props.onBlur, - ref: props.ref as React.Ref, - }; -} - -/** - * Helper to extract field props for Checkbox components - * @param controller - React Hook Form controller object - * @returns Props for Checkbox component - */ -export function getCheckboxProps(controller: unknown) { - const props = getFormKitProps(controller); - return { - name: props.name, - defaultChecked: props.value as boolean, - onChange: (checked: boolean) => { - props.onChange(checked); - }, - onBlur: props.onBlur, - ref: props.ref as React.Ref, - }; -} - -/** - * Helper to extract field props for RadioGroup components - * @param controller - React Hook Form controller object - * @returns Props for RadioGroup component - */ -export function getRadioGroupProps(controller: unknown) { - const props = getFormKitProps(controller); - return { - name: props.name, - defaultValue: props.value as string | number, - onChange: (value: string | number) => { - props.onChange(value); - }, - onBlur: props.onBlur, - ref: props.ref as React.Ref, - }; -} - -/** - * Get error message from React Hook Form controller - * @param controller - React Hook Form controller object - * @returns Error message or null - */ -export function getErrorMessage(controller: unknown): string | null { - if (!isReactHookFormController(controller)) { - return null; - } - return controller.fieldState.error?.message ?? null; -} - -/** - * Check if field is touched in React Hook Form - * @param controller - React Hook Form controller object - * @returns True if field is touched - */ -export function isTouched(controller: unknown): boolean { - if (!isReactHookFormController(controller)) { - return false; - } - return controller.fieldState.isTouched; -} - -/** - * Check if field is dirty in React Hook Form - * @param controller - React Hook Form controller object - * @returns True if field is dirty - */ -export function isDirty(controller: unknown): boolean { - if (!isReactHookFormController(controller)) { - return false; - } - return controller.fieldState.isDirty; -} - -/** - * Check if field has error in React Hook Form - * @param controller - React Hook Form controller object - * @returns True if field has error - */ -export function hasError(controller: unknown): boolean { - if (!isReactHookFormController(controller)) { - return false; - } - return controller.fieldState.invalid; -} diff --git a/src/adapters/zod.ts b/src/adapters/zod.ts deleted file mode 100644 index 3d0b99c..0000000 --- a/src/adapters/zod.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Zod adapter for FormKit UI validation - * Converts Zod schemas to ValidatorFn for use with FormKit validation system - */ - -import type { ValidatorFn, ValidationResult } from '../validation/types'; -import type { FieldValue } from '../utils/types'; - -/** - * Type guard to check if Zod is available - */ -function isZodSchema(schema: unknown): schema is { - safeParse: (value: unknown) => { - success: boolean; - error?: { issues: Array<{ message: string }> }; - }; - safeParseAsync: ( - value: unknown, - ) => Promise<{ success: boolean; error?: { issues: Array<{ message: string }> } }>; - _def: { typeName: string }; -} { - return ( - typeof schema === 'object' && - schema !== null && - 'safeParse' in schema && - typeof (schema as { safeParse: unknown }).safeParse === 'function' - ); -} - -/** - * Convert a Zod schema to a ValidatorFn - * @param schema - Zod schema to convert - * @param customMessage - Optional custom error message (overrides Zod's message) - * @returns ValidatorFn that validates using the Zod schema - * @throws Error if Zod is not available or schema is invalid - */ -export function zodValidator(schema: unknown, customMessage?: string): ValidatorFn { - if (!isZodSchema(schema)) { - throw new Error( - 'Invalid Zod schema provided. Make sure you have Zod installed and are passing a valid schema.', - ); - } - - return (value: FieldValue): ValidationResult => { - const result = schema.safeParse(value); - - if (result.success) { - return null; - } - - // Use custom message if provided, otherwise use first Zod error message - if (customMessage) { - return customMessage; - } - - if (result.error && result.error.issues.length > 0) { - return result.error.issues[0].message; - } - - return 'Validation failed'; - }; -} - -/** - * Convert a Zod schema to an async ValidatorFn - * @param schema - Zod schema to convert - * @param customMessage - Optional custom error message (overrides Zod's message) - * @returns Async ValidatorFn that validates using the Zod schema - * @throws Error if Zod is not available or schema is invalid - */ -export function zodAsyncValidator( - schema: unknown, - customMessage?: string, -): (value: FieldValue) => Promise { - if (!isZodSchema(schema)) { - throw new Error( - 'Invalid Zod schema provided. Make sure you have Zod installed and are passing a valid schema.', - ); - } - - return async (value: FieldValue): Promise => { - const result = await schema.safeParseAsync(value); - - if (result.success) { - return null; - } - - // Use custom message if provided, otherwise use first Zod error message - if (customMessage) { - return customMessage; - } - - if (result.error && result.error.issues.length > 0) { - return result.error.issues[0].message; - } - - return 'Validation failed'; - }; -} - -/** - * Helper to check if Zod is available in the project - * @returns true if Zod is available, false otherwise - */ -export function isZodAvailable(): boolean { - try { - // Try to access Zod - in a real project this would be an import - // For testing purposes, we check if a Zod-like object can be created - return typeof isZodSchema === 'function'; - } catch { - return false; - } -} diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx deleted file mode 100644 index 4d866a8..0000000 --- a/src/components/Checkbox.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Checkbox component with validation and error handling - */ - -import { forwardRef, useId, useEffect, 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'; - -/** - * Props for Checkbox component - */ -export interface CheckboxProps { - /** Checkbox name attribute */ - name: string; - /** Checkbox label text (shown next to checkbox) */ - checkboxLabel?: string; - /** Container label (optional, for grouping) */ - label?: string; - /** Default checked state */ - defaultChecked?: boolean; - /** Whether checkbox is in indeterminate state */ - indeterminate?: boolean; - /** 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 checkbox wrapper */ - checkboxClassName?: 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; - /** Change handler */ - onChange?: (checked: boolean) => void; - /** Blur handler */ - onBlur?: () => void; - /** Focus handler */ - onFocus?: () => void; - /** Validation change handler */ - onValidationChange?: (isValid: boolean) => void; -} - -/** - * Checkbox component with validation and error handling - */ -export const Checkbox = forwardRef( - ( - { - name, - checkboxLabel, - label, - defaultChecked = false, - indeterminate = false, - required = false, - disabled = false, - readOnly = false, - className = '', - checkboxClassName = '', - validationRules = [], - validateOn = 'change', - debounce, - showError = true, - autoDismissError, - hint, - onChange, - onBlur, - onFocus, - onValidationChange, - }, - ref, - ) => { - const generatedId = useId(); - const fieldId = `checkbox-${name}-${generatedId}`; - const errorId = `${fieldId}-error`; - const hintId = hint ? `${fieldId}-hint` : undefined; - - const internalRef = useRef(null); - - // Add required validator if required prop is true - const effectiveRules = useMemo(() => { - if (required) { - // Add a "must be checked" validator - const checkedValidator: ValidationRule = { - validator: (value: unknown) => { - return value === true ? null : 'This field is required'; - }, - }; - return [checkedValidator, ...validationRules]; - } - return validationRules; - }, [required, validationRules]); - - // Field state management - const { value, isTouched, handleChange, handleBlur, handleFocus } = useFormField({ - initialValue: defaultChecked, - disabled, - readOnly, - onChange: (val) => { - const checked = val as boolean; - onChange?.(checked); - if (validateOn === 'change') { - validate(checked); - } - }, - onBlur: () => { - onBlur?.(); - if (validateOn === 'blur') { - validate(value as boolean); - } - }, - onFocus, - }); - - // Validation - const { errors, isValid, validate } = useValidation({ - rules: effectiveRules, - 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 - useEffect(() => { - if (errors.length > 0) { - setErrors(errors); - } else if (error !== null) { - setErrors([]); - } - }, [errors, error, setErrors]); - - // Handle indeterminate state - useEffect(() => { - if (internalRef.current) { - internalRef.current.indeterminate = indeterminate; - } - }, [indeterminate]); - - // Handle ref forwarding - const setRefs = (element: HTMLInputElement | null) => { - internalRef.current = element; - if (typeof ref === 'function') { - ref(element); - } else if (ref) { - ref.current = element; - } - }; - - const hasError = isTouched && error !== null; - const showHint = hint && !hasError; - const checked = value as boolean; - - return ( -
- {label ? ( -
- {label} - {required && *} -
- ) : ( - - )} -
- - {checkboxLabel && ( - - )} -
- {showHint && ( -
- {hint} -
- )} - {showError && hasError && ( - - )} -
- ); - }, -); - -Checkbox.displayName = 'Checkbox'; diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx deleted file mode 100644 index 3d0a528..0000000 --- a/src/components/ErrorMessage.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * ErrorMessage component for displaying error messages - */ - -import type { ErrorSeverity } from '../errors/types'; - -/** - * Props for ErrorMessage component - */ -export interface ErrorMessageProps { - /** Error message to display */ - message: string; - /** Error severity level */ - severity?: ErrorSeverity; - /** Error code */ - code?: string; - /** Whether to show the error code */ - showCode?: boolean; - /** Unique ID for the error (for aria-describedby) */ - id?: string; - /** Custom CSS class */ - className?: string; - /** Callback when error is dismissed (if dismissible) */ - onDismiss?: () => void; - /** Whether the error can be dismissed */ - dismissible?: boolean; -} - -/** - * ErrorMessage component for displaying formatted error messages - */ -export const ErrorMessage = ({ - message, - severity = 'error', - code, - showCode = false, - id, - className = '', - onDismiss, - dismissible = false, -}: ErrorMessageProps) => { - const severityClass = `formkit-error-${severity}`; - const displayMessage = showCode && code ? `[${code}] ${message}` : message; - - return ( - - ); -}; - -ErrorMessage.displayName = 'ErrorMessage'; diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx deleted file mode 100644 index c501023..0000000 --- a/src/components/FormField.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * FormField wrapper component for consistent field layout - */ - -import { ReactNode } from 'react'; - -/** - * Props for FormField component - */ -export interface FormFieldProps { - /** Field label */ - label?: string; - /** Whether field is required */ - required?: boolean; - /** Hint or help text */ - hint?: string; - /** Error message to display */ - error?: string | null; - /** Whether to show the error */ - showError?: boolean; - /** Unique ID for the field (for aria-describedby) */ - fieldId?: string; - /** Custom CSS class for container */ - className?: string; - /** Custom CSS class for label */ - labelClassName?: string; - /** Custom CSS class for hint */ - hintClassName?: string; - /** Custom CSS class for error */ - errorClassName?: string; - /** The form field element(s) to wrap */ - children: ReactNode; -} - -/** - * FormField wrapper component that provides consistent layout - * for labels, hints, errors, and form controls - */ -export const FormField = ({ - label, - required = false, - hint, - error, - showError = true, - fieldId, - className = '', - labelClassName = '', - hintClassName = '', - errorClassName = '', - children, -}: FormFieldProps) => { - const hasError = showError && error !== null && error !== undefined && error !== ''; - const showHint = hint && !hasError; - const hintId = fieldId && hint ? `${fieldId}-hint` : undefined; - const errorId = fieldId && error ? `${fieldId}-error` : undefined; - - return ( -
- {label && ( - - )} -
{children}
- {showHint && ( -
- {hint} -
- )} - {hasError && ( - - )} -
- ); -}; - -FormField.displayName = 'FormField'; diff --git a/src/components/Input.tsx b/src/components/Input.tsx deleted file mode 100644 index 2a4b68a..0000000 --- a/src/components/Input.tsx +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Input component with validation and error handling - */ - -import { forwardRef, useId, useEffect } from 'react'; -import type { InputType } from '../utils/types'; -import type { ValidationRule } from '../validation/types'; -import { useFormField } from '../hooks/useFormField'; -import { useValidation } from '../hooks/useValidation'; -import { useFieldError } from '../hooks/useFieldError'; - -/** - * Props for Input component - */ -export interface InputProps { - /** Input name attribute */ - name: string; - /** Input type */ - type?: InputType; - /** Label text */ - label?: string; - /** Placeholder text */ - placeholder?: string; - /** Default value */ - defaultValue?: string | 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; - /** Maximum length */ - maxLength?: number; - /** Minimum value (for number type) */ - min?: number; - /** Maximum value (for number type) */ - max?: number; - /** Step value (for number type) */ - step?: number; - /** Pattern for validation */ - pattern?: string; - /** Autocomplete attribute */ - autoComplete?: string; - /** Hint or help text */ - hint?: string; - /** Change handler */ - onChange?: (value: string | number) => void; - /** Blur handler */ - onBlur?: () => void; - /** Focus handler */ - onFocus?: () => void; - /** Validation change handler */ - onValidationChange?: (isValid: boolean) => void; -} - -/** - * Input component with validation and error handling - */ -export const Input = forwardRef( - ( - { - name, - type = 'text', - label, - placeholder, - defaultValue = '', - required = false, - disabled = false, - readOnly = false, - className = '', - inputClassName = '', - validationRules = [], - validateOn = 'blur', - debounce, - showError = true, - autoDismissError, - maxLength, - min, - max, - step, - pattern, - autoComplete, - hint, - onChange, - onBlur, - onFocus, - onValidationChange, - }, - ref, - ) => { - const generatedId = useId(); - const fieldId = `input-${name}-${generatedId}`; - const errorId = `${fieldId}-error`; - const hintId = hint ? `${fieldId}-hint` : undefined; - - // Field state management - const { value, isTouched, handleChange, handleBlur, handleFocus } = useFormField({ - initialValue: defaultValue, - disabled, - readOnly, - onChange: (val) => { - onChange?.(val as string | number); - if (validateOn === 'change') { - validate(val as string | number); - } - }, - onBlur: () => { - onBlur?.(); - if (validateOn === 'blur') { - validate(value as string | number); - } - }, - 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([]); - } - - const hasError = isTouched && error !== null; - const showHint = hint && !hasError; - - return ( -
- {label ? ( - - ) : ( - - )} - - {showHint && ( -
- {hint} -
- )} - {showError && hasError && ( - - )} -
- ); - }, -); - -Input.displayName = 'Input'; diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx deleted file mode 100644 index 1607919..0000000 --- a/src/components/RadioGroup.tsx +++ /dev/null @@ -1,265 +0,0 @@ -/** - * RadioGroup component with validation and error handling - */ - -import { forwardRef, useId, useEffect, useRef } from 'react'; -import type { ValidationRule } from '../validation/types'; -import type { FieldOption } from '../utils/types'; -import { useFormField } from '../hooks/useFormField'; -import { useValidation } from '../hooks/useValidation'; -import { useFieldError } from '../hooks/useFieldError'; - -/** - * Props for RadioGroup component - */ -export interface RadioGroupProps { - /** Radio group name attribute */ - name: string; - /** Available radio options */ - options: FieldOption[]; - /** Label text for the group */ - label?: string; - /** Layout direction */ - direction?: 'horizontal' | 'vertical'; - /** Default selected value */ - defaultValue?: string | 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 radio wrapper */ - radioClassName?: 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; - /** Change handler */ - onChange?: (value: string | number) => void; - /** Blur handler */ - onBlur?: () => void; - /** Focus handler */ - onFocus?: () => void; - /** Validation change handler */ - onValidationChange?: (isValid: boolean) => void; -} - -/** - * RadioGroup component with validation and error handling - */ -export const RadioGroup = forwardRef( - ( - { - name, - options, - label, - direction = 'vertical', - defaultValue = '', - required = false, - disabled = false, - readOnly = false, - className = '', - radioClassName = '', - validationRules = [], - validateOn = 'change', - debounce, - showError = true, - autoDismissError, - hint, - onChange, - onBlur, - onFocus, - onValidationChange, - }, - ref, - ) => { - const generatedId = useId(); - const fieldId = `radio-${name}-${generatedId}`; - const errorId = `${fieldId}-error`; - const hintId = hint ? `${fieldId}-hint` : undefined; - - const radioRefs = useRef>(new Map()); - - // Field state management - const { value, isTouched, setValue, handleBlur, handleFocus } = useFormField({ - initialValue: defaultValue, - disabled, - readOnly, - onBlur: () => { - onBlur?.(); - if (validateOn === 'blur') { - validate(value as string | number); - } - }, - onFocus, - }); - - // Custom handleChange for radio - const handleRadioChange = (optionValue: string | number) => { - if (disabled || readOnly) { - return; - } - - setValue(optionValue); - onChange?.(optionValue); - - if (validateOn === 'change') { - validate(optionValue); - } - }; - - // 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 - useEffect(() => { - if (errors.length > 0) { - setErrors(errors); - } else if (error !== null) { - setErrors([]); - } - }, [errors, error, setErrors]); - - // Handle keyboard navigation for radio groups - const handleKeyDown = (event: React.KeyboardEvent, currentIndex: number) => { - if (disabled || readOnly) { - return; - } - - const isHorizontal = direction === 'horizontal'; - const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown'; - const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp'; - - if (event.key === nextKey || event.key === prevKey) { - event.preventDefault(); - - const enabledOptions = options.filter((opt) => !opt.disabled); - const currentEnabledIndex = enabledOptions.findIndex( - (opt) => opt.value === options[currentIndex].value, - ); - - let nextIndex: number; - if (event.key === nextKey) { - nextIndex = (currentEnabledIndex + 1) % enabledOptions.length; - } else { - nextIndex = - currentEnabledIndex === 0 ? enabledOptions.length - 1 : currentEnabledIndex - 1; - } - - const nextOption = enabledOptions[nextIndex]; - const nextInput = radioRefs.current.get(nextOption.value); - - if (nextInput) { - nextInput.focus(); - handleRadioChange(nextOption.value); - } - } - }; - - const hasError = isTouched && error !== null; - const showHint = hint && !hasError; - - return ( -
- {label && ( - - {label} - {required && *} - - )} -
- {options.map((option, index) => { - const radioId = `${fieldId}-${option.value}`; - const isChecked = value === option.value; - const isDisabled = disabled || option.disabled; - - return ( -
- { - if (el) { - radioRefs.current.set(option.value, el); - } else { - radioRefs.current.delete(option.value); - } - }} - type="radio" - id={radioId} - name={name} - value={option.value} - checked={isChecked} - onChange={() => handleRadioChange(option.value)} - onBlur={handleBlur} - onFocus={handleFocus} - onKeyDown={(e) => handleKeyDown(e, index)} - disabled={isDisabled} - required={required} - className={`formkit-radio ${hasError ? 'formkit-radio-error' : ''} ${ - isTouched && isValid ? 'formkit-radio-valid' : '' - }`} - aria-invalid={hasError} - /> - -
- ); - })} -
- {showHint && ( -
- {hint} -
- )} - {showError && hasError && ( - - )} -
- ); - }, -); - -RadioGroup.displayName = 'RadioGroup'; diff --git a/src/components/Select.tsx b/src/components/Select.tsx deleted file mode 100644 index b945877..0000000 --- a/src/components/Select.tsx +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Select component with validation and error handling - */ - -import { forwardRef, useId, useEffect } from 'react'; -import type { ValidationRule } from '../validation/types'; -import { useFormField } from '../hooks/useFormField'; -import { useValidation } from '../hooks/useValidation'; -import { useFieldError } from '../hooks/useFieldError'; - -/** - * Option for select dropdown - */ -export interface SelectOption { - /** Option value */ - value: string | number; - /** Option display label */ - label: string; - /** Whether option is disabled */ - disabled?: boolean; -} - -/** - * Props for Select component - */ -export interface SelectProps { - /** Select name attribute */ - name: string; - /** Available options */ - options: SelectOption[]; - /** Label text */ - label?: string; - /** Placeholder for empty selection */ - placeholder?: string; - /** Empty option label (overrides placeholder) */ - emptyLabel?: string; - /** Default value */ - defaultValue?: string | number | string[] | number[]; - /** Allow multiple selections */ - multiple?: boolean; - /** 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 select element */ - selectClassName?: 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; - /** Change handler */ - onChange?: (value: string | number | string[] | number[]) => void; - /** Blur handler */ - onBlur?: () => void; - /** Focus handler */ - onFocus?: () => void; - /** Validation change handler */ - onValidationChange?: (isValid: boolean) => void; -} - -/** - * Select component with validation and error handling - */ -export const Select = forwardRef( - ( - { - name, - options, - label, - placeholder, - emptyLabel, - defaultValue = '', - multiple = false, - required = false, - disabled = false, - readOnly = false, - className = '', - selectClassName = '', - validationRules = [], - validateOn = 'blur', - debounce, - showError = true, - autoDismissError, - hint, - onChange, - onBlur, - onFocus, - onValidationChange, - }, - ref, - ) => { - const generatedId = useId(); - const fieldId = `select-${name}-${generatedId}`; - const errorId = `${fieldId}-error`; - const hintId = hint ? `${fieldId}-hint` : undefined; - - // Field state management - const { value, isTouched, setValue, handleBlur, handleFocus } = useFormField({ - initialValue: defaultValue, - disabled, - readOnly, - onBlur: () => { - onBlur?.(); - if (validateOn === 'blur') { - validate(value as string | number | string[] | number[]); - } - }, - onFocus, - }); - - // Custom handleChange for select to support multiple selections - const handleChange = (event: React.ChangeEvent) => { - if (disabled || readOnly) { - return; - } - - let newValue: string | number | string[] | number[]; - - if (multiple) { - // For multiple select, extract all selected values - const selectedOptions = Array.from(event.target.selectedOptions); - newValue = selectedOptions.map((option) => option.value); - } else { - // For single select, use the value directly - newValue = event.target.value; - } - - setValue(newValue); - onChange?.(newValue); - - if (validateOn === 'change') { - validate(newValue); - } - }; - - // 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 - useEffect(() => { - if (errors.length > 0) { - setErrors(errors); - } else if (error !== null) { - setErrors([]); - } - }, [errors, error, setErrors]); - - const hasError = isTouched && error !== null; - const showHint = hint && !hasError; - const showEmptyOption = !multiple && (emptyLabel || placeholder); - const emptyOptionLabel = emptyLabel || placeholder || 'Select an option'; - - // Convert value to appropriate type for select element - const selectValue = multiple - ? Array.isArray(value) - ? value.map((v) => String(v)) - : [] - : (value as string | number); - - return ( -
- {label ? ( - - ) : ( - - )} - - {showHint && ( -
- {hint} -
- )} - {showError && hasError && ( - - )} -
- ); - }, -); - -Select.displayName = 'Select'; diff --git a/src/components/Textarea.tsx b/src/components/Textarea.tsx deleted file mode 100644 index d323943..0000000 --- a/src/components/Textarea.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Textarea component with validation and error handling - */ - -import { forwardRef, useId, useEffect, useRef, useState } from 'react'; -import type { ValidationRule } from '../validation/types'; -import { useFormField } from '../hooks/useFormField'; -import { useValidation } from '../hooks/useValidation'; -import { useFieldError } from '../hooks/useFieldError'; - -/** - * Props for Textarea component - */ -export interface TextareaProps { - /** Textarea 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 textarea element */ - textareaClassName?: 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; - /** Number of visible text rows */ - rows?: number; - /** Number of visible text columns */ - cols?: number; - /** Maximum length */ - maxLength?: number; - /** Whether to auto-resize based on content */ - autoResize?: boolean; - /** Show character count */ - showCount?: boolean; - /** Hint or help text */ - hint?: string; - /** Change handler */ - onChange?: (value: string) => void; - /** Blur handler */ - onBlur?: () => void; - /** Focus handler */ - onFocus?: () => void; - /** Validation change handler */ - onValidationChange?: (isValid: boolean) => void; -} - -/** - * Textarea component with validation and error handling - */ -export const Textarea = forwardRef( - ( - { - name, - label, - placeholder, - defaultValue = '', - required = false, - disabled = false, - readOnly = false, - className = '', - textareaClassName = '', - validationRules = [], - validateOn = 'blur', - debounce, - showError = true, - autoDismissError, - rows = 3, - cols, - maxLength, - autoResize = false, - showCount = false, - hint, - onChange, - onBlur, - onFocus, - onValidationChange, - }, - ref, - ) => { - const generatedId = useId(); - const fieldId = `textarea-${name}-${generatedId}`; - const errorId = `${fieldId}-error`; - const hintId = hint ? `${fieldId}-hint` : undefined; - const countId = showCount ? `${fieldId}-count` : undefined; - - const internalRef = useRef(null); - const [charCount, setCharCount] = useState(defaultValue.length); - - // Field state management - const { value, isTouched, handleChange, handleBlur, handleFocus } = useFormField({ - initialValue: defaultValue, - disabled, - readOnly, - onChange: (val) => { - const strValue = val as string; - setCharCount(strValue.length); - onChange?.(strValue); - if (validateOn === 'change') { - validate(strValue); - } - if (autoResize && internalRef.current) { - adjustHeight(internalRef.current); - } - }, - onBlur: () => { - onBlur?.(); - if (validateOn === 'blur') { - validate(value as string); - } - }, - 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 - useEffect(() => { - if (errors.length > 0) { - setErrors(errors); - } else if (error !== null) { - setErrors([]); - } - }, [errors, error, setErrors]); - - // Auto-resize functionality - const adjustHeight = (element: HTMLTextAreaElement) => { - element.style.height = 'auto'; - element.style.height = `${element.scrollHeight}px`; - }; - - // Setup auto-resize on mount - useEffect(() => { - if (autoResize && internalRef.current) { - adjustHeight(internalRef.current); - } - }, [autoResize]); - - // Handle ref forwarding - const setRefs = (element: HTMLTextAreaElement | null) => { - internalRef.current = element; - if (typeof ref === 'function') { - ref(element); - } else if (ref) { - ref.current = element; - } - }; - - const hasError = isTouched && error !== null; - const showHint = hint && !hasError; - const showCounter = showCount && (maxLength !== undefined || charCount > 0); - const isOverLimit = maxLength !== undefined && charCount > maxLength; - - return ( -
- {label ? ( - - ) : ( - - )} -