├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── Control.tsx ├── app.tsx ├── fields.ts ├── index.html └── validations.ts ├── package-lock.json ├── package.json ├── src └── react-jeff.ts ├── tsconfig.json └── types └── email-regex.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .rts2* 4 | dist 5 | example-dist 6 | .cache 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "useTabs": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Jeff 2 | 3 | > A Good Form Library 4 | 5 | - ~800 bytes minified+gzip 6 | - Easy to learn API 7 | - Write your form code in a way that is reusable and testable 8 | - Seamless sync and async form validations (including on form submit) 9 | - Tons of utilty features out of the box 10 | - Written with React Hooks 11 | - Typed with TypeScript 12 | 13 | ## Install 14 | 15 | ``` 16 | npm install react-jeff 17 | ``` 18 | 19 | ## Usage 20 | 21 | 22 | ```js 23 | import React from "react" 24 | import { useField, useForm } from "react-jeff" 25 | 26 | /** 27 | * 1. Write some validations that accept an input value and return an array of errors. 28 | * (If the array is empty, the value is considered valid) 29 | */ 30 | 31 | function validateUsername(value) { 32 | let errs = [] 33 | if (value.length < 3) errs.push("Must be at least 3 characters long") 34 | if (!/^[a-z0-9_-]*$/i.test(value)) errs.push("Must only contain alphanumeric characters or dashes/underscores") 35 | if (!/^[a-z0-9]/i.test(value)) errs.push("Must start with alphanumeric character") 36 | if (!/[a-z0-9]$/i.test(value)) errs.push("Must end with alphanumeric character") 37 | return errs 38 | } 39 | 40 | function validatePassword(value) { 41 | let errs = [] 42 | if (value.length < 6) errs.push("Must be at least 6 characters long") 43 | if (!/[a-z]/.test(value)) errs.push("Must contain at least one lowercase letter") 44 | if (!/[A-Z]/.test(value)) errs.push("Must contain at least one uppercase letter") 45 | if (!/[0-9]/.test(value)) errs.push("Must contain at least one number") 46 | return errs 47 | } 48 | 49 | /** 50 | * 2. Write some custom components that you'll reuse for all of your forms. 51 | */ 52 | 53 | function Form({ onSubmit, ...props }) { 54 | return ( 55 |
{ 56 | event.preventDefault() // Make sure you call `event.preventDefault()` on your forms! 57 | onSubmit() 58 | }}/> 59 | ) 60 | } 61 | 62 | function Input({ onChange, ...props }) { 63 | return ( 64 | { 65 | onChange(event.currentTarget.value) // Make sure all of your inputs call `props.onChange` with the new value. 66 | }} /> 67 | ) 68 | } 69 | 70 | /** 71 | * 3. Create your form! 72 | */ 73 | 74 | function SignupForm() { 75 | // Create some fields... 76 | let username = useField({ 77 | defaultValue: "", 78 | required: true, 79 | validations: [validateUsername], 80 | }) 81 | 82 | let password = useField({ 83 | defaultValue: "", 84 | required: true, 85 | validations: [validatePassword], 86 | }) 87 | 88 | // Create your onSubmit handler... 89 | function onSubmit() { 90 | // Do something with the form... 91 | } 92 | 93 | // Create your form... 94 | let form = useForm({ 95 | fields: [username, password], 96 | onSubmit: onSubmit, 97 | }) 98 | 99 | // Write your UI! 100 | return ( 101 | 102 | 103 | 104 | 105 |
106 | ) 107 | } 108 | ``` 109 | 110 | ## API 111 | 112 | ### `useField()` 113 | 114 | Call `useField()` to create a single field in a form. 115 | 116 | ```js 117 | let field = useField({ 118 | defaultValue: (value), // ....... (Required) The default value of the field. 119 | validations: [(...errors)], // .. (Optional) Validations to run when the field is `validate()`'d. 120 | required: boolean, // ........... (Optional) Should the field be required? 121 | disabled: boolean, // ........... (Optional) Should the field be disabled? 122 | readOnly: boolean, // ........... (Optional) Should the field be readOnly? 123 | }) 124 | ``` 125 | 126 | ```js 127 | field == { 128 | value: (value), // ......... The current value of the field. 129 | defaultValue: (value), // .. The `defaultValue` passed into `useField({ defaultValue })`. 130 | 131 | dirty: boolean, // ......... Has the field been changed from its defaultValue? 132 | touched: boolean, // ....... Has the element this field is attached to been focused previously? 133 | focused: boolean, // ....... Is the element this field is attached to currently focused? 134 | blurred: boolean, // ....... Has the element this field is attached to been focused and then blurred? 135 | 136 | validating: boolean, // .... Is the field validating itself? 137 | valid: boolean, // ......... Is the field currently valid? (must have no errors, and if the field is required, must not be empty) 138 | errors: [(...errors)], // .. The collected errors returned by `opts.validations` 139 | 140 | required: boolean, // ...... Is the field required? 141 | disabled: boolean, // ...... Is the field disabled? 142 | readOnly: boolean, // ...... Is the field readOnly? 143 | 144 | setValue: Function, // ..... Call with a value to manually update the value of the field. 145 | setRequired: Function, // .. Call with true/false to manually set the `required` state of the field. 146 | setDisabled: Function, // .. Call with true/false to manually set the `disabled` state of the field. 147 | setReadOnly: Function, // .. Call with true/false to manually set the `readOnly` state of the field. 148 | 149 | reset: Function, // ........ Reset the field to its default state. 150 | validate: Function, // ..... Manually tell the field to validate itself (updating other fields). 151 | 152 | // Props to pass into an component to attach the `field` to it: 153 | props: { 154 | value: (value), // ....... The current value (matches `field.value`). 155 | 156 | onChange: Function, // ... An `onChange` handler to update the value of the field. 157 | onFocus: Function, // .... An `onFocus` handler to update the focused/touched/blurred states of the field. 158 | onBlur: Function, // ..... An `onFocus` handler to update the focused/blurred states of the field. 159 | 160 | required: boolean, // .... Should the element be `required`? 161 | disabled: boolean, // .... Should the element be `disabled`? 162 | readOnly: boolean, // .... Should the element be `readOnly`? 163 | }, 164 | } 165 | ``` 166 | 167 | ### `useForm()` 168 | 169 | Call `useForm()` with to create a single form. 170 | 171 | ```js 172 | let form = useForm({ 173 | fields: [(...fields)], // .. All of the fields created via `useField()` that are part of the form. 174 | onSubmit: Function, // ..... A submit handler for the form that receives the form submit event. 175 | }) 176 | ``` 177 | 178 | ```js 179 | form == { 180 | fieldErrors: [(...errors)], // ... The collected errors from all of the fields in the form. 181 | submitErrors: [(...errors)], // .. The errors returned by `opts.onSubmit`. 182 | 183 | submitted: boolean, // ........... Has the form been submitted at any point? 184 | submitting: boolean, // .......... Is the form currently submitting? 185 | 186 | focused: boolean, // ............. Are *any* of the fields in the form currently `focused`? 187 | touched: boolean, // ............. Are *any* of the fields in the form currently `touched`? 188 | dirty: boolean, // ............... Are *any* of the fields in the form currently `dirty`? 189 | valid: boolean, // ............... Are *all* of the fields in the form currently `valid`? 190 | validating: boolean, // .......... Are *any* of the fields in the form currently `validating`? 191 | 192 | reset: Function, // .............. Reset all of the fields in the form. 193 | validate: Function, // ........... Validate all of the fields in the form. 194 | submit: Function, // ............. Submit the form manually. 195 | 196 | // Props to pass into a form component to attach the `form` to it: 197 | props: { 198 | onSubmit: Function, // ......... An onSubmit handler to pass to an element that submits the form. 199 | }, 200 | } 201 | ``` 202 | 203 | 228 | -------------------------------------------------------------------------------- /example/Control.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Form, Field } from "../src/react-jeff" 3 | import format from "pretty-format" 4 | 5 | export interface ControlProps { 6 | children: React.ReactNode 7 | form: Form 8 | field: Field 9 | } 10 | 11 | export function Control(props: ControlProps) { 12 | return ( 13 |
14 | {props.children} 15 | {(props.field.dirty || props.form.submitted) && ( 16 | <> 17 | {props.field.validating && ( 18 |

Validating...

19 | )} 20 | {props.field.errors && ( 21 |
    22 | {props.field.errors.map(err => { 23 | return
  • {err}
  • 24 | })} 25 |
26 | )} 27 | 28 | )} 29 |
30 | 				{"field = "}
31 | 				{format(props.field, {
32 | 					printFunctionName: false,
33 | 				})}
34 | 			
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /example/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { render } from "react-dom" 3 | import { useForm } from "../src/react-jeff" 4 | import * as fields from "./fields" 5 | import { Control } from "./Control" 6 | import format from "pretty-format" 7 | 8 | function Form({ onSubmit, ...props }) { 9 | return ( 10 |
{ 12 | event.preventDefault() 13 | onSubmit() 14 | }} 15 | {...props} 16 | /> 17 | ) 18 | } 19 | 20 | function TextInput({ onChange, ...props }) { 21 | return ( 22 | onChange(event.currentTarget.value)} {...props} /> 23 | ) 24 | } 25 | 26 | function CheckboxInput({ onChange, ...props }) { 27 | return ( 28 | onChange(event.currentTarget.checked)} 31 | {...props} 32 | /> 33 | ) 34 | } 35 | 36 | function SignupForm() { 37 | let email = fields.useEmailField() 38 | let username = fields.useUsernameField() 39 | let password = fields.usePasswordField() 40 | let confirmPassword = fields.useConfirmPasswordField(password.value) 41 | let acceptTerms = fields.useAcceptTermsField() 42 | 43 | function handleSubmit() { 44 | // ... 45 | } 46 | 47 | let form = useForm({ 48 | fields: [email, username, password, confirmPassword], 49 | onSubmit: handleSubmit, 50 | }) 51 | 52 | return ( 53 | 54 | 55 | 56 | 62 | 63 | 64 | 65 | 71 | 72 | 73 | 74 | 80 | 81 | 82 | 83 | 89 | 90 | 91 | 92 | 93 | 94 | 97 | 98 |
 99 | 				{"form = "}
100 | 				{format(form, {
101 | 					printFunctionName: false,
102 | 				})}
103 | 			
104 |
105 | ) 106 | } 107 | 108 | render(, document.getElementById("root")) 109 | -------------------------------------------------------------------------------- /example/fields.ts: -------------------------------------------------------------------------------- 1 | import { useField, useForm } from "../src/react-jeff" 2 | import * as validations from "./validations" 3 | 4 | export function useEmailField() { 5 | return useField({ 6 | defaultValue: "", 7 | validations: [validations.validateEmail, validations.validateEmailUnused], 8 | }) 9 | } 10 | 11 | export function useUsernameField() { 12 | return useField({ 13 | defaultValue: "", 14 | validations: [ 15 | validations.validateUsername, 16 | validations.validateUsernameUnused, 17 | ], 18 | }) 19 | } 20 | 21 | export function usePasswordField() { 22 | return useField({ 23 | defaultValue: "", 24 | validations: [validations.validatePassword], 25 | }) 26 | } 27 | 28 | export function useConfirmPasswordField(password: string) { 29 | return useField({ 30 | defaultValue: "", 31 | validations: [ 32 | value => validations.validateConfirmPassword(value, password), 33 | ], 34 | }) 35 | } 36 | 37 | export function useAcceptTermsField() { 38 | return useField({ 39 | defaultValue: false, 40 | validations: [validations.validateAcceptTerms], 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Jeff Example 8 | 64 | 65 | 66 |
67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /example/validations.ts: -------------------------------------------------------------------------------- 1 | import emailRegex from "email-regex" 2 | 3 | let EMAIL_REGEX = emailRegex({ exact: true }) 4 | 5 | export function validateEmail(value: string) { 6 | let errors = [] 7 | 8 | if (!EMAIL_REGEX.test(value)) { 9 | errors.push("Must be valid email") 10 | } 11 | 12 | return errors 13 | } 14 | 15 | export function validateEmailUnused(value: string) { 16 | return fakeAsyncApiRequest().then(() => { 17 | let errors = [] 18 | if (value === "me@thejameskyle.com") { 19 | errors.push(`There's already an account signed up with that email`) 20 | } 21 | return errors 22 | }) 23 | } 24 | 25 | export function validateUsername(value: string) { 26 | let errors = [] 27 | 28 | if (value.length < 3) { 29 | errors.push("Must be at least 3 characters long") 30 | } 31 | 32 | if (!/^[a-z0-9_-]*$/i.test(value)) { 33 | errors.push( 34 | "Must only contain alphanumeric characters or dashes/underscores", 35 | ) 36 | } 37 | 38 | if (!/^[a-z0-9]/i.test(value)) { 39 | errors.push("Must start with alphanumeric character") 40 | } 41 | 42 | if (!/[a-z0-9]$/i.test(value)) { 43 | errors.push("Must end with alphanumeric character") 44 | } 45 | 46 | return errors 47 | } 48 | 49 | export function validateUsernameUnused(value: string) { 50 | return fakeAsyncApiRequest().then(() => { 51 | let errors = [] 52 | if (value === "jamiebuilds") { 53 | errors.push(`That username is already taken`) 54 | } 55 | return errors 56 | }) 57 | } 58 | 59 | export function validatePassword(value: string) { 60 | let errors = [] 61 | 62 | if (value.length < 6) { 63 | errors.push("Must be at least 6 characters long") 64 | } 65 | 66 | if (!/[a-z]/.test(value)) { 67 | errors.push("Must contain at least one lowercase letter") 68 | } 69 | 70 | if (!/[A-Z]/.test(value)) { 71 | errors.push("Must contain at least one uppercase letter") 72 | } 73 | 74 | if (!/[0-9]/.test(value)) { 75 | errors.push("Must contain at least one number") 76 | } 77 | 78 | return errors 79 | } 80 | 81 | export function validateConfirmPassword(value: string, password: string) { 82 | let errors = [] 83 | 84 | if (value !== password) { 85 | errors.push("Must match password") 86 | } 87 | 88 | return errors 89 | } 90 | 91 | export function validateAcceptTerms(value: boolean) { 92 | let errors = [] 93 | 94 | if (value !== true) { 95 | errors.push("Must agree to terms and conditions") 96 | } 97 | 98 | return errors 99 | } 100 | 101 | function fakeAsyncApiRequest() { 102 | return new Promise(res => setTimeout(res, 1000)) 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-jeff", 3 | "version": "1.2.1", 4 | "description": "A Good Form Library", 5 | "main": "dist/react-jeff.js", 6 | "types": "dist/react-jeff.d.ts", 7 | "source": "src/react-jeff.ts", 8 | "amdName": "ReactJeff", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "format": "prettier --write '**/*.{ts,json,html,md}'", 14 | "build": "rm -rf dist && tsc --noEmit && microbundle --no-compress", 15 | "prepublish": "npm run build", 16 | "test": "ava", 17 | "example:build": "parcel build example/index.html --out-dir example-dist", 18 | "start": "parcel example/index.html --out-dir example-dist" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "form", 23 | "forms", 24 | "hooks", 25 | "hook", 26 | "final-form", 27 | "final", 28 | "formik", 29 | "library", 30 | "simple", 31 | "easy", 32 | "plain", 33 | "async", 34 | "validation", 35 | "validate", 36 | "valid", 37 | "dirty", 38 | "focused", 39 | "touch", 40 | "touched" 41 | ], 42 | "author": "Jamie Kyle ", 43 | "license": "MIT", 44 | "peerDependencies": { 45 | "react": "^16.8.4" 46 | }, 47 | "devDependencies": { 48 | "@types/pretty-format": "^20.0.1", 49 | "@types/react": "^16.8.8", 50 | "email-regex": "^3.0.0", 51 | "husky": "^1.3.1", 52 | "lint-staged": "^8.1.5", 53 | "microbundle": "^0.11.0", 54 | "parcel": "^1.12.1", 55 | "prettier": "^1.16.4", 56 | "pretty-format": "^24.7.0", 57 | "react": "^16.0.0", 58 | "react-dom": "^16.8.4", 59 | "typescript": "^3.3.3333" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "lint-staged" 64 | } 65 | }, 66 | "lint-staged": { 67 | "*.{ts,json,md}": [ 68 | "prettier --write", 69 | "git add" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/react-jeff.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | /** 4 | * A close enough check to test if a value is a Synthetic Event or not. 5 | * See: https://github.com/reactjs/rfcs/pull/112 6 | * @private 7 | */ 8 | function isReactSyntheticEvent(value: any) { 9 | if (typeof value !== "object" || value === null) return false 10 | if (typeof value.bubbles !== "boolean") return false 11 | if (typeof value.cancellable !== "boolean") return false 12 | if (typeof value.defaultPrevented !== "boolean") return false 13 | if (typeof value.eventPhase !== "number") return false 14 | if (typeof value.isTrusted !== "boolean") return false 15 | if (typeof value.timestamp !== "number") return false 16 | if (typeof value.type !== "string") return false 17 | return true 18 | } 19 | 20 | function isEmptyValue(value: any) { 21 | if (value === "") return true 22 | if (value === null) return true 23 | if (typeof value === "undefined") return true 24 | return false 25 | } 26 | 27 | /** 28 | * A function that accepts a value and returns an array of errors. If the array 29 | * is empty, the value is assumed to be valid. The validation can also return 30 | * asynchronously using a promise. 31 | * 32 | * Validation errors default to being strings, but can have any shape. 33 | */ 34 | export interface Validation { 35 | (value: Val): Err[] | Promise 36 | } 37 | 38 | /** 39 | * Options to pass into `useField(opts)` 40 | */ 41 | export interface FieldOptions { 42 | /** 43 | * The default value of the field. 44 | */ 45 | defaultValue: Val 46 | /** 47 | * Validations to run when the field is `validate()`'d. 48 | */ 49 | validations?: Array> 50 | /** 51 | * Should the field be required? 52 | */ 53 | required?: boolean 54 | /** 55 | * Should the field be disabled? 56 | */ 57 | disabled?: boolean 58 | /** 59 | * Should the field be readOnly? 60 | */ 61 | readOnly?: boolean 62 | } 63 | 64 | /** 65 | * Options to pass into `useForm(opts)` 66 | */ 67 | export interface FormOptions { 68 | /** 69 | * All of the fields created via `useField()` that are part of the form. 70 | */ 71 | fields: Array> 72 | /** 73 | * A submit handler for the form that receives the form submit event and can 74 | * return additional validation errors. May also return asynchronously using 75 | * a promise. 76 | */ 77 | onSubmit: () => void | Err[] | Promise | Promise 78 | } 79 | 80 | /** 81 | * The object returned by `useField()` 82 | */ 83 | export interface Field { 84 | /** 85 | * The current value of the field. 86 | */ 87 | value: Val 88 | /** 89 | * The `defaultValue` passed into `useField({ defaultValue })`. 90 | */ 91 | defaultValue: Val 92 | 93 | /** 94 | * Has the field been changed from its defaultValue? 95 | */ 96 | dirty: boolean 97 | /** 98 | * Has the element this field is attached to been focused previously? 99 | */ 100 | touched: boolean 101 | /** 102 | * Is the element this field is attached to currently focused? 103 | */ 104 | focused: boolean 105 | /** 106 | * Has the element this field is attached to been focused and then blurred? 107 | */ 108 | blurred: boolean 109 | 110 | /** 111 | * Is the field validating itself? 112 | */ 113 | validating: boolean 114 | /** 115 | * Is the field currently valid? 116 | * 117 | * Must have no errors, and if the field is required, must not be empty. 118 | */ 119 | valid: boolean 120 | /** 121 | * The collected errors returned by `opts.validations` 122 | */ 123 | errors: Err[] 124 | 125 | /** 126 | * Is the field required? 127 | */ 128 | required: boolean 129 | /** 130 | * Is the field disabled? 131 | */ 132 | disabled: boolean 133 | /** 134 | * Is the field readOnly? 135 | */ 136 | readOnly: boolean 137 | 138 | /** 139 | * Call with a value to manually update the value of the field. 140 | */ 141 | setValue: (value: Val) => any 142 | /** 143 | * Call with true/false to manually set the `required` state of the field. 144 | */ 145 | setRequired: (required: boolean) => void 146 | /** 147 | * Call with true/false to manually set the `disabled` state of the field. 148 | */ 149 | setDisabled: (disabled: boolean) => void 150 | /** 151 | * Call with true/false to manually set the `readOnly` state of the field. 152 | */ 153 | setReadOnly: (readonly: boolean) => void 154 | 155 | /** 156 | * Reset the field to its default state. 157 | */ 158 | reset: () => void 159 | /** 160 | * Manually tell the field to validate itself (updating other fields). 161 | */ 162 | validate: () => Promise 163 | 164 | /** 165 | * Props to pass into an component to attach the `field` to it 166 | */ 167 | props: { 168 | /** 169 | * The current value (matches `field.value`). 170 | */ 171 | value: Val 172 | 173 | /** 174 | * An `onChange` handler to update the value of the field. 175 | */ 176 | onChange: (value: Val) => void 177 | /** 178 | * An `onFocus` handler to update the focused/touched/blurred states of the field. 179 | */ 180 | onFocus: () => void 181 | /** 182 | * An `onFocus` handler to update the focused/blurred states of the field. 183 | */ 184 | onBlur: () => void 185 | 186 | /** 187 | * Should the element be `required`? 188 | */ 189 | required: boolean 190 | /** 191 | * Should the element be `disabled`? 192 | */ 193 | disabled: boolean 194 | /** 195 | * Should the element be `readOnly`? 196 | */ 197 | readOnly: boolean 198 | } 199 | } 200 | 201 | /** 202 | * The object returned by `useForm()` 203 | */ 204 | export interface Form { 205 | /** 206 | * The collected errors from all of the fields in the form. 207 | */ 208 | fieldErrors: Array 209 | /** 210 | * The errors returned by `opts.onSubmit`. 211 | */ 212 | submitErrors: Array 213 | /** 214 | * Has the form been submitted at any point? 215 | */ 216 | submitted: boolean 217 | /** 218 | * Is the form currently submitting? 219 | */ 220 | submitting: boolean 221 | /** 222 | * Are *any* of the fields in the form currently `focused`? 223 | */ 224 | focused: boolean 225 | /** 226 | * Are *any* of the fields in the form currently `touched`? 227 | */ 228 | touched: boolean 229 | /** 230 | * Are *any* of the fields in the form currently `dirty`? 231 | */ 232 | dirty: boolean 233 | /** 234 | * Are *all* of the fields in the form currently `valid`? 235 | */ 236 | valid: boolean 237 | /** 238 | * Are *any* of the fields in the form currently `validating`? 239 | */ 240 | validating: boolean 241 | /** 242 | * Reset all of the fields in the form. 243 | */ 244 | reset: () => void 245 | /** 246 | * Validate all of the fields in the form. 247 | */ 248 | validate: () => Promise 249 | /** 250 | * Submit the form manually. 251 | */ 252 | submit: () => Promise 253 | 254 | /** 255 | * Props to pass into a form component to attach the `form` to it: 256 | */ 257 | props: { 258 | /** 259 | * An `onSubmit` handler to pass to an element that submits the form. 260 | */ 261 | onSubmit: () => Promise 262 | } 263 | } 264 | 265 | /** 266 | * Call `useField()` to create a single field in a form. 267 | */ 268 | export function useField( 269 | options: FieldOptions, 270 | ): Field { 271 | let defaultValue = options.defaultValue 272 | let validations = options.validations || [] 273 | 274 | let [value, setValue] = React.useState(defaultValue) 275 | let [errors, setErrors] = React.useState([]) 276 | let [validating, setValidating] = React.useState(false) 277 | let [focused, setFocused] = React.useState(false) 278 | let [blurred, setBlurred] = React.useState(false) 279 | let [touched, setTouched] = React.useState(false) 280 | let [dirty, setDirty] = React.useState(false) 281 | let [required, setRequired] = React.useState(options.required || false) 282 | let [disabled, setDisabled] = React.useState(options.disabled || false) 283 | let [readOnly, setReadOnly] = React.useState(options.readOnly || false) 284 | 285 | let onFocus = React.useCallback(() => { 286 | setFocused(true) 287 | setTouched(true) 288 | }, []) 289 | 290 | let onBlur = React.useCallback(() => { 291 | setFocused(false) 292 | setBlurred(true) 293 | }, []) 294 | 295 | let valueRef = React.useRef(value) 296 | 297 | function reset() { 298 | valueRef.current = defaultValue; 299 | setValue(defaultValue) 300 | setErrors([]) 301 | setValidating(false) 302 | setBlurred(false) 303 | if (!focused) setTouched(false) 304 | setDirty(false) 305 | } 306 | 307 | function validate() { 308 | let initValue = valueRef.current 309 | let errorsMap: Err[][] = [] 310 | 311 | setValidating(true) 312 | 313 | let promises = validations.map((validation, index) => { 314 | return Promise.resolve() 315 | .then(() => validation(initValue)) 316 | .then(errors => { 317 | errorsMap[index] = errors 318 | if (Object.is(initValue, valueRef.current)) { 319 | setErrors(errorsMap.flat(1)) 320 | } 321 | }) 322 | }) 323 | 324 | return Promise.all(promises) 325 | .then(() => { 326 | if (Object.is(initValue, valueRef.current)) { 327 | setValidating(false) 328 | } 329 | }) 330 | .catch(err => { 331 | if (Object.is(initValue, valueRef.current)) { 332 | setValidating(false) 333 | } 334 | throw err 335 | }) 336 | } 337 | 338 | let setValueHandler = (value: Val) => { 339 | valueRef.current = value 340 | setDirty(true) 341 | setValue(value) 342 | setErrors([]) 343 | validate() 344 | } 345 | 346 | let onChange = (value: Val) => { 347 | if (isReactSyntheticEvent(value)) { 348 | throw new TypeError( 349 | "Expected `field.onChange` to be called with a value, not an event", 350 | ) 351 | } 352 | setValueHandler(value) 353 | } 354 | 355 | let valid = !errors.length && (required ? !isEmptyValue(value) : true) 356 | 357 | return { 358 | value, 359 | errors, 360 | defaultValue, 361 | setValue: setValueHandler, 362 | focused, 363 | blurred, 364 | touched, 365 | dirty, 366 | valid, 367 | validating, 368 | required, 369 | disabled, 370 | readOnly, 371 | setRequired, 372 | setDisabled, 373 | setReadOnly, 374 | reset, 375 | validate, 376 | props: { 377 | value, 378 | required, 379 | disabled, 380 | readOnly, 381 | onFocus, 382 | onBlur, 383 | onChange, 384 | }, 385 | } 386 | } 387 | 388 | /** 389 | * Call `useForm()` with to create a single form. 390 | */ 391 | export function useForm(options: FormOptions): Form { 392 | let fields = options.fields 393 | let onSubmit = options.onSubmit 394 | let [submitted, setSubmitted] = React.useState(false) 395 | let [submitting, setSubmitting] = React.useState(false) 396 | let [submitErrors, setSubmitErrors] = React.useState([]) 397 | 398 | let focused = fields.some(field => field.focused) 399 | let touched = fields.some(field => field.touched) 400 | let dirty = fields.some(field => field.dirty) 401 | let valid = fields.every(field => field.valid) 402 | let validating = fields.some(field => field.validating) 403 | 404 | let fieldErrors = fields.reduce( 405 | (errors, field) => errors.concat(field.errors), 406 | [] as Err[], 407 | ) 408 | 409 | function reset() { 410 | setSubmitted(false) 411 | setSubmitting(false) 412 | setSubmitErrors([]) 413 | fields.forEach(field => field.reset()) 414 | } 415 | 416 | function validate() { 417 | return Promise.all(fields.map(field => field.validate())).then(() => {}) 418 | } 419 | 420 | function onSubmitHandler() { 421 | setSubmitted(true) 422 | setSubmitting(true) 423 | 424 | return Promise.resolve() 425 | .then(() => onSubmit() as any) // Dunno why TS is mad 426 | .then((errs: void | Err[]) => { 427 | setSubmitting(false) 428 | setSubmitErrors(errs || []) 429 | }) 430 | .catch((err: Error) => { 431 | setSubmitting(false) 432 | throw err 433 | }) 434 | } 435 | 436 | return { 437 | fieldErrors, 438 | submitErrors, 439 | submitted, 440 | submitting, 441 | focused, 442 | touched, 443 | dirty, 444 | valid, 445 | validating, 446 | validate, 447 | reset, 448 | submit: onSubmitHandler, 449 | props: { 450 | onSubmit: onSubmitHandler, 451 | }, 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | { 3 | "exclude": ["example", "dist"], 4 | "compilerOptions": { 5 | /* Basic Options */ 6 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 7 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /types/email-regex.d.ts: -------------------------------------------------------------------------------- 1 | declare module "email-regex" { 2 | export interface EmailRegexOpts { 3 | exact?: boolean 4 | } 5 | 6 | export default function emailRegex(opts?: EmailRegexOpts): RegExp 7 | } 8 | --------------------------------------------------------------------------------