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 7394b65..368ed3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +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.9", "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", @@ -56,6 +57,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -122,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", @@ -371,6 +387,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz", @@ -734,6 +757,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -774,6 +798,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1505,6 +1530,106 @@ "node": "20 || >=22" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1987,6 +2112,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", @@ -2666,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", @@ -2696,6 +2831,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2707,6 +2843,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2756,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", @@ -2967,6 +3105,39 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -3100,6 +3271,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3522,6 +3694,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4089,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", @@ -4107,6 +4279,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -4366,6 +4545,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4430,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", @@ -5003,6 +5184,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -5417,6 +5615,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6032,6 +6237,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6050,6 +6309,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6096,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", @@ -6709,7 +6985,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6724,6 +6999,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7383,6 +7686,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7474,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", @@ -7490,7 +7793,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7503,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", @@ -7962,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", @@ -8267,6 +8567,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -8407,6 +8740,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8556,6 +8903,139 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "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": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -8634,6 +9114,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8910,6 +9391,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9035,6 +9517,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9820,6 +10303,73 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -9908,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 cae9b15..c3fa353 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", @@ -64,6 +65,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:cov": "vitest run --coverage", "format": "prettier . --check", "format:write": "prettier . --write", "changeset": "changeset", 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 ? ( - - ) : ( - - )} -