mirror of
https://github.com/chenasraf/formplex-react.git
synced 2026-05-17 17:48:11 +00:00
feat: poc
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
[*]
|
||||
tab_width = 2
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
templates/
|
||||
scaffolds/
|
||||
webpack.config.js
|
||||
dist/
|
||||
27
.eslintrc.js
Normal file
27
.eslintrc.js
Normal file
@@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint'],
|
||||
rules: {
|
||||
indent: ['error', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'never'],
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
}
|
||||
130
.gitignore
vendored
Normal file
130
.gitignore
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
templates/
|
||||
scaffolds/
|
||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"printWidth": 100,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"formplex"
|
||||
]
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Chen Asraf
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# FormPlex - React
|
||||
|
||||
Handle forms in your React apps with incredible ease.
|
||||
|
||||
FormPlex lets you handle React forms without the hassle.
|
||||
|
||||
## Example Form
|
||||
|
||||
```tsx
|
||||
import React from 'react'
|
||||
import { useForm } from 'formplex-react'
|
||||
|
||||
interface MyFormData {
|
||||
companyName: string
|
||||
employeesCount: number
|
||||
revenueRange: '$0-$50K' | '$50-$500K' | '$500K-$1M' | '$1M+'
|
||||
}
|
||||
|
||||
export const MyForm: React.FC = () => {
|
||||
const { field, handleSubmit, isValid, errors } = useForm<MyFormData>({
|
||||
onSubmit(values, e) {
|
||||
console.log('Form submitted:', values)
|
||||
fetch('/submit', { method: 'POST', body: JSON.stringify(values) })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
{...field('companyName', {
|
||||
minLength: 5,
|
||||
maxLength: 50,
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.companyName && <div style={{ color: 'red' }}>{errors.companyName.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
{...field('employeesCount', {
|
||||
parse: Number,
|
||||
pattern: /^\d+$/,
|
||||
required: true,
|
||||
validate: (n) => (n < 1 ? 'Must have at least 1 employee.' : null),
|
||||
})}
|
||||
/>
|
||||
{errors.employeesCount && (
|
||||
<div style={{ color: 'red' }}>{errors.employeesCount.message}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<select {...field('revenueRange', { required: true })}>
|
||||
<option value="$0-$50K">$0-$50K</option>
|
||||
<option value="$50-$500K">$50-$500K</option>
|
||||
<option value="$500K-$1M">$500K-$1M</option>
|
||||
<option value="$1M+">$1M+</option>
|
||||
</select>
|
||||
{errors.revenueRange && <div style={{ color: 'red' }}>{errors.revenueRange.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" disabled={!isValid}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
61
example/example.tsx
Normal file
61
example/example.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react'
|
||||
import { useForm } from '../src/use-form'
|
||||
|
||||
interface MyFormData {
|
||||
companyName: string
|
||||
employeesCount: number
|
||||
revenueRange: '$0-$50K' | '$50-$500K' | '$500K-$1M' | '$1M+'
|
||||
}
|
||||
|
||||
export const MyForm: React.FC = () => {
|
||||
const { field, handleSubmit, isValid, errors } = useForm<MyFormData>({
|
||||
onSubmit(values, e) {
|
||||
console.log('Form submitted', values)
|
||||
fetch('/submit', { method: 'POST', body: JSON.stringify(values) })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
{...field('companyName', {
|
||||
minLength: 5,
|
||||
maxLength: 50,
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.companyName && <div style={{ color: 'red' }}>{errors.companyName.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
{...field('employeesCount', {
|
||||
parse: Number,
|
||||
pattern: /^\d+$/,
|
||||
required: true,
|
||||
validate: (n) => (n < 1 ? 'Must have at least 1 employee.' : null),
|
||||
})}
|
||||
/>
|
||||
{errors.employeesCount && (
|
||||
<div style={{ color: 'red' }}>{errors.employeesCount.message}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<select {...field('revenueRange', { required: true })}>
|
||||
<option value="$0-$50K">$0-$50K</option>
|
||||
<option value="$50-$500K">$50-$500K</option>
|
||||
<option value="$500K-$1M">$500K-$1M</option>
|
||||
<option value="$1M+">$1M+</option>
|
||||
</select>
|
||||
{errors.revenueRange && <div style={{ color: 'red' }}>{errors.revenueRange.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" disabled={!isValid}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "formplex-react",
|
||||
"version": "0.1.0",
|
||||
"description": "Incredibly easy & flexible React form hooks",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/chenasraf/formplex-react",
|
||||
"author": "Chen Asraf <contact@casraf.dev>",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "webpack --mode=production --node-env=production",
|
||||
"build:dev": "webpack --mode=development",
|
||||
"build:prod": "webpack --mode=production --node-env=production",
|
||||
"watch": "webpack --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": ">16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.42.1",
|
||||
"@typescript-eslint/parser": "^5.42.1",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"eslint": "^8.27.0",
|
||||
"eslint-config-react": "^1.1.7",
|
||||
"eslint-plugin-react": "^7.31.10",
|
||||
"prettier": "^2.7.1",
|
||||
"ts-loader": "^9.4.1",
|
||||
"typedoc": "^0.23.20",
|
||||
"typescript": "^4.8.4",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">16"
|
||||
}
|
||||
}
|
||||
1
src/index.ts
Normal file
1
src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './use-form'
|
||||
399
src/use-form.ts
Normal file
399
src/use-form.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* Options for the `useForm` hook
|
||||
*/
|
||||
export interface UseFormOptions<T> {
|
||||
/**
|
||||
* The initial state of the form. This will cause the form inputs to be pre-filled with the given data.
|
||||
* Any missing field will be empty.
|
||||
*/
|
||||
initialState?: Partial<T>
|
||||
|
||||
/**
|
||||
* Callback that will be fired when the form is submitted. The callback will receive the form data.
|
||||
*/
|
||||
onSubmit?: (values: T, e: React.FormEvent<HTMLFormElement>) => void
|
||||
|
||||
/**
|
||||
* Map of custom error messages for the default validation methods.
|
||||
* @see {ErrorStrings}
|
||||
*/
|
||||
errorMessages?: Partial<ErrorStrings>
|
||||
|
||||
/**
|
||||
* Decide when to auto validate the form. Defaults to `onChange`.
|
||||
*
|
||||
* `immediate` - Show validation errors as soon as the hook mounts.
|
||||
*
|
||||
* `onChange` - Show validations after the user has changed the input.
|
||||
*
|
||||
* `onBlur` - Show validations after the user has blurred the input.
|
||||
*
|
||||
* `never` - Show validations after the user has submitted the form only, or on manual trigger.
|
||||
*/
|
||||
autoValidateBehavior?: 'immediate' | 'onChange' | 'onBlur' | 'never'
|
||||
}
|
||||
|
||||
export interface UseFormReturn<T> {
|
||||
/**
|
||||
* Register a field input named `key` to the form. This will return props that should be injected into the input.
|
||||
* See each of the options for more information.
|
||||
*/
|
||||
field: <K extends keyof T, E>(key: K, options?: FieldOptions<T, K>) => FieldReturn<E>
|
||||
|
||||
/**
|
||||
* A mapping of the error messages given for each field.
|
||||
* Each property is the name of the field and the value is the error message, if any.
|
||||
*
|
||||
* If there is no error, the value will be `undefined`.
|
||||
*/
|
||||
errors: Partial<Record<keyof T, ErrorMessage>>
|
||||
|
||||
/**
|
||||
* The current form data, after parsing.
|
||||
*/
|
||||
state: Partial<T>
|
||||
|
||||
/**
|
||||
* The current form data, before parsing.
|
||||
*/
|
||||
rawState: Partial<Record<keyof T, string | string[] | number>>
|
||||
|
||||
/**
|
||||
* Indicates whether the form is valid.
|
||||
*/
|
||||
isValid: boolean
|
||||
|
||||
/**
|
||||
* A callback that will submit the form. This will also call `onSubmit` if it was provided.
|
||||
* You should inject this into the form's `onSubmit` prop.
|
||||
*/
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
|
||||
/**
|
||||
* Set multiple fields at once. This will cause the form to re-render.
|
||||
*/
|
||||
setValues: (values: Partial<T>) => void
|
||||
|
||||
/**
|
||||
* Set a single field. This will cause the form to re-render.
|
||||
*/
|
||||
setValue: <K extends keyof T>(key: K, value: T[K]) => void
|
||||
}
|
||||
|
||||
export interface FieldOptions<T, K extends keyof T = keyof T> {
|
||||
/**
|
||||
* If `true`, the field will be required.
|
||||
*
|
||||
* This will cause the form to be invalid if the field is empty
|
||||
* and an error message will be provided in `errors`.
|
||||
*/
|
||||
required?: boolean
|
||||
|
||||
/**
|
||||
* Minimum length (in characters) for the field.
|
||||
*
|
||||
* This will cause the form to be invalid if the field is shorter than the given length,
|
||||
* and an error message will be provided in `errors`.
|
||||
*/
|
||||
minLength?: number
|
||||
|
||||
/**
|
||||
* Maximum length (in characters) for the field.
|
||||
*
|
||||
* This will cause the form to be invalid if the field is longer than the given length,
|
||||
* and an error message will be provided in `errors`.
|
||||
*/
|
||||
maxLength?: number
|
||||
|
||||
/**
|
||||
* A regular expression that the field must match.
|
||||
*
|
||||
* This will cause the input to not update if the value does not match the given pattern.
|
||||
* To cause a validation error for a pattern, use `validate` instead.
|
||||
*/
|
||||
pattern?: string | RegExp
|
||||
|
||||
/**
|
||||
* Map of custom error messages for the default validation methods.
|
||||
*/
|
||||
errorMessages?: Partial<ErrorStrings>
|
||||
|
||||
/**
|
||||
* A custom validation function for the field.
|
||||
*
|
||||
* If it returns an empty string, `null` or `undefined`, the field is valid.
|
||||
*
|
||||
* Otherwise, the field is invalid and the returned string will be the error message.
|
||||
*/
|
||||
validate?: (value: T[K]) => string | undefined | null
|
||||
|
||||
/**
|
||||
* A custom parser for the field. This will be called when the field is updated, and will cause the `state` object
|
||||
* to be updated with the result of this function.
|
||||
*
|
||||
* `rawState` will always contain the raw value of the field as it was placed here.
|
||||
*/
|
||||
parse?: (value: string) => T[K]
|
||||
|
||||
/**
|
||||
* A callback for changing the input, which also contains the parsed value.
|
||||
*
|
||||
* Never use `onChange` on the input field directly, or it will not work to update the form state.
|
||||
* Use this callback instead, which acts right after the input's `onChange` callback.
|
||||
*/
|
||||
onChange?: (event: ChangeEvent, value: T[K]) => void
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
/**
|
||||
* The type of validation error on the field, such as `required`, `minLength`, `maxLength`, and `pattern`, or
|
||||
* `validate` for custom validations
|
||||
*/
|
||||
error: keyof ErrorStrings | 'validate'
|
||||
/**
|
||||
* The error message for the field.
|
||||
*/
|
||||
message: string
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export type FieldReturn<E> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any
|
||||
required?: boolean
|
||||
onChange: (event: ChangeEvent) => void
|
||||
onBlur: (event: ChangeEvent) => void
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type ChangeEvent = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
target: any
|
||||
}
|
||||
/** @internal */
|
||||
type BlurEvent = ChangeEvent
|
||||
|
||||
/**
|
||||
* Map of custom error messages for the default validation methods.
|
||||
*/
|
||||
export interface ErrorStrings {
|
||||
/**
|
||||
* Error message for when the field is required but missing.
|
||||
*
|
||||
* Default: `"Required"`
|
||||
*/
|
||||
required: string
|
||||
/**
|
||||
* Error message for when the field length is too short.
|
||||
*
|
||||
* Can either be a string, or a function resolving to a string.
|
||||
*
|
||||
* Default:
|
||||
* ```ts
|
||||
* (n) => `Must be at least ${n} characters long`
|
||||
* ```
|
||||
*/
|
||||
minLength: string | ((length: number) => string)
|
||||
/**
|
||||
* Error message for when the field length is too long.
|
||||
*
|
||||
* Can either be a string, or a function resolving to a string.
|
||||
*
|
||||
* Default:
|
||||
* ```ts
|
||||
* (n) => `Must be no more than ${n} characters long`
|
||||
* ```
|
||||
*/
|
||||
maxLength: string | ((length: number) => string)
|
||||
/**
|
||||
* Error message for when the field value does not match the given pattern.
|
||||
*
|
||||
* Can either be a string, or a function resolving to a string.
|
||||
*
|
||||
* Default:
|
||||
* ```ts
|
||||
* (p) => `Must match pattern ${p}`
|
||||
* ```
|
||||
*/
|
||||
pattern: string | ((pattern: string | RegExp) => string)
|
||||
}
|
||||
|
||||
/** The main hook for using forms. See each option and return property for more information. */
|
||||
export function useForm<T>({
|
||||
initialState = {},
|
||||
errorMessages = {},
|
||||
autoValidateBehavior = 'onChange',
|
||||
onSubmit,
|
||||
}: UseFormOptions<T> = {}): UseFormReturn<T> {
|
||||
errorMessages = {
|
||||
required: 'Required',
|
||||
minLength: (n) => `Must be at least ${n} characters long`,
|
||||
maxLength: (n) => `Must be no more than ${n} characters long`,
|
||||
pattern: (p) => `Must match pattern ${p}`,
|
||||
...errorMessages,
|
||||
}
|
||||
const [state, setState] = React.useState<Partial<T>>(initialState ?? {})
|
||||
const [rawState, setRawState] = React.useState<
|
||||
Partial<Record<keyof T, string | string[] | number>>
|
||||
>(
|
||||
Object.entries(state).reduce(
|
||||
(acc, [key, value]) => ({ ...acc, [key]: String(value) }),
|
||||
{} as Partial<Record<keyof T, string | string[] | number>>,
|
||||
),
|
||||
)
|
||||
const [errors, setErrors] = React.useState<Partial<Record<keyof T, ErrorMessage>>>({})
|
||||
const isValid = React.useMemo(() => !Object.values(errors).some(Boolean), [errors])
|
||||
|
||||
function setValue<K extends keyof T>(
|
||||
key: K,
|
||||
value: T[K] | string | string[] | number,
|
||||
raw: T[K] | string | string[] | number = value,
|
||||
) {
|
||||
setRawState((prev) => ({ ...prev, [key]: raw }))
|
||||
setState((s) => ({ ...s, [key]: value }))
|
||||
}
|
||||
|
||||
function validate<K extends keyof T>(
|
||||
key: K,
|
||||
value: T[K],
|
||||
options: FieldOptions<T, K>,
|
||||
): ErrorMessage | undefined {
|
||||
const parseStr = (o: string | ((...args: unknown[]) => string), val: unknown) =>
|
||||
typeof o === 'function' ? o(val) : o
|
||||
|
||||
const errorStrings = {
|
||||
...errorMessages,
|
||||
...(options?.errorMessages ?? {}),
|
||||
} as ErrorStrings
|
||||
|
||||
// Required
|
||||
if (options?.required && !value) {
|
||||
return { error: 'required', message: errorStrings.required }
|
||||
}
|
||||
|
||||
// Min length
|
||||
if (options?.minLength && value && (value as string | string[]).length < options.minLength) {
|
||||
return { error: 'minLength', message: parseStr(errorStrings.minLength, options.minLength) }
|
||||
}
|
||||
|
||||
// Max length
|
||||
if (options?.maxLength && value && (value as string | string[]).length > options.maxLength) {
|
||||
return { error: 'maxLength', message: parseStr(errorStrings.maxLength, options.maxLength) }
|
||||
}
|
||||
|
||||
// Custom validations
|
||||
const validRes = options?.validate?.(value as T[K])
|
||||
|
||||
if (![undefined, null, ''].includes(validRes)) {
|
||||
return { error: 'validate', message: validRes }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function parseValue<K extends keyof T, E extends HTMLElement = HTMLInputElement>(
|
||||
value: string | number | string[] | null | undefined,
|
||||
options?: FieldOptions<T, K>,
|
||||
): string | number | string[] | undefined | T[K] {
|
||||
const parsed = options?.parse ? options.parse(value as string) : value
|
||||
|
||||
if (value !== '' && options?.pattern) {
|
||||
if (!new RegExp(options.pattern).test(parsed as string)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return parsed!
|
||||
}
|
||||
|
||||
function fieldProps<K extends keyof T, E>(key: K, options?: FieldOptions<T, K>): FieldReturn<E> {
|
||||
if (autoValidateBehavior === 'immediate') {
|
||||
setErrorsFromRaw<K>(key, rawState[key], options)
|
||||
}
|
||||
|
||||
return {
|
||||
value: rawState[key] ?? '',
|
||||
onChange: (e: ChangeEvent) => {
|
||||
const value = parseValue(e.target.value, options)
|
||||
if (value === undefined) {
|
||||
return
|
||||
}
|
||||
setValueFromRaw<K>(key, e.target.value, options)
|
||||
|
||||
if (autoValidateBehavior === 'onChange') {
|
||||
if (setErrorsFromRaw(key, e.target.value, options)) {
|
||||
options?.onChange?.(e, value as T[K])
|
||||
}
|
||||
}
|
||||
},
|
||||
onBlur: (e: BlurEvent) => {
|
||||
const value = parseValue(e.target.value, options)
|
||||
if (value === undefined) {
|
||||
return
|
||||
}
|
||||
setValueFromRaw<K>(key, e.target.value, options)
|
||||
|
||||
if (autoValidateBehavior === 'onBlur') {
|
||||
if (setErrorsFromRaw(key, e.target.value, options)) {
|
||||
options?.onChange?.(e, value as T[K])
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setErrorsFromRaw<K extends keyof T>(
|
||||
key: K,
|
||||
value: string | string[] | number,
|
||||
options: FieldOptions<T, K>,
|
||||
): boolean {
|
||||
const parsed = parseValue(value, options)
|
||||
const error = validate(key, parsed as T[K], options ?? {})
|
||||
|
||||
if (error) {
|
||||
setErrors((e) => ({ ...e, [key]: error }))
|
||||
return false
|
||||
}
|
||||
setErrors((e) => {
|
||||
const copy = { ...e }
|
||||
delete copy[key]
|
||||
return copy
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function setValueFromRaw<K extends keyof T>(
|
||||
key: K,
|
||||
value: string | string[] | number,
|
||||
options: FieldOptions<T, K>,
|
||||
) {
|
||||
const parsed = parseValue(value, options)
|
||||
setValue(key, parsed as T[K], value)
|
||||
}
|
||||
|
||||
function setValues(values: Partial<T>): void {
|
||||
Object.entries(values).forEach(([key, value]) => setValue(key as keyof T, value as T[keyof T]))
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>): void {
|
||||
e.preventDefault()
|
||||
if (isValid) {
|
||||
onSubmit?.(state as T, e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
field: fieldProps,
|
||||
errors,
|
||||
state,
|
||||
rawState,
|
||||
isValid,
|
||||
setValue,
|
||||
setValues: setValues,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noImplicitAny": true,
|
||||
"module": "CommonJS",
|
||||
"target": "es5",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"removeComments": false,
|
||||
"declaration": true,
|
||||
"declarationDir": "dist"
|
||||
// "outDir": "dist",
|
||||
},
|
||||
"files": [
|
||||
"src/index.ts"
|
||||
]
|
||||
}
|
||||
51
webpack.config.js
Normal file
51
webpack.config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// Generated using webpack-cli https://github.com/webpack/webpack-cli
|
||||
|
||||
const path = require('path')
|
||||
const CopyPlugin = require('copy-webpack-plugin')
|
||||
|
||||
const isProduction = process.env.NODE_ENV == 'production'
|
||||
|
||||
/** @type {import('webpack').WebpackOptionsNormalized} */
|
||||
const config = {
|
||||
mode: isProduction ? 'production' : 'development',
|
||||
entry: {
|
||||
index: './src/index.ts',
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
library: {
|
||||
type: 'commonjs2',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// Add your plugins here
|
||||
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
|
||||
new CopyPlugin({
|
||||
patterns: [{ from: 'package.json', to: '.' }],
|
||||
}),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(ts|tsx)$/i,
|
||||
loader: 'ts-loader',
|
||||
exclude: ['/node_modules/'],
|
||||
},
|
||||
// {
|
||||
// test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
|
||||
// type: "asset",
|
||||
// },
|
||||
|
||||
// Add your rules for custom modules here
|
||||
// Learn more about loaders from https://webpack.js.org/loaders/
|
||||
],
|
||||
},
|
||||
externals: {
|
||||
react: 'commonjs react',
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js'],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
Reference in New Issue
Block a user