├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ ├── deploy-to-github-pages.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── __tests__ ├── combineValidators.ts ├── errors.tsx ├── fieldsListener.tsx ├── focusHandling.tsx ├── formStatus.tsx ├── listenFields.tsx ├── sanitization.tsx ├── strategies.tsx ├── toOptionalValidator.ts ├── utils │ └── promises.ts └── validation.tsx ├── docs ├── credit-card-error.gif └── credit-card-valid.gif ├── package.json ├── src ├── combineValidators.ts ├── helpers.ts ├── index.ts ├── toOptionalValidator.ts ├── types.ts └── useForm.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsup.config.ts ├── vite.config.mts ├── vitest-setup.ts ├── website ├── .gitignore ├── 404.html ├── index.html ├── package.json ├── src │ ├── App.tsx │ ├── components │ │ ├── Input.tsx │ │ ├── Link.tsx │ │ └── Page.tsx │ ├── forms │ │ ├── AsyncSubmissionForm.tsx │ │ ├── BasicForm.tsx │ │ ├── CheckboxesForm.tsx │ │ ├── CreditCardForm.tsx │ │ ├── Dynamic.tsx │ │ ├── FieldsListenerForm.tsx │ │ ├── IBANForm.tsx │ │ ├── InputMaskingForm.tsx │ │ └── StrategiesForm.tsx │ ├── index.tsx │ └── utils │ │ ├── promises.ts │ │ └── router.ts ├── tsconfig.json ├── vite.config.mts └── yarn.lock └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | dist/ 3 | tsup.config.ts 4 | vite.config.mts 5 | vitest-setup.ts 6 | website/ 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { devDependencies } = require("./package.json"); 2 | const path = require("path"); 3 | 4 | module.exports = { 5 | extends: [ 6 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | 10 | parserOptions: { 11 | project: path.resolve(__dirname + "/tsconfig.json"), 12 | }, 13 | settings: { 14 | react: { 15 | version: devDependencies.react.replace(/[^.\d]/g, ""), 16 | }, 17 | }, 18 | rules: { 19 | "@typescript-eslint/no-unsafe-call": "off", 20 | "@typescript-eslint/no-unsafe-member-access": "off", 21 | "@typescript-eslint/restrict-template-expressions": "off", 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build & test 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: lts/* 22 | cache: yarn 23 | 24 | - run: yarn install --pure-lockfile 25 | - run: yarn typecheck 26 | - run: yarn test 27 | - run: yarn build 28 | 29 | - name: Build docs 30 | run: cd docs && yarn && yarn build 31 | 32 | - name: Deploy 33 | if: "contains('refs/heads/main', github.ref)" 34 | uses: peaceiris/actions-gh-pages@v3 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./docs/build 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy-to-github-pages.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | 5 | jobs: 6 | deploy: 7 | name: Deploy to GitHub Pages 8 | timeout-minutes: 60 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | 17 | - name: Install dependencies and build 18 | run: | 19 | yarn install --pure-lockfile 20 | yarn prepack 21 | yarn --cwd website install --pure-lockfile 22 | yarn --cwd website build 23 | 24 | - name: Deploy 25 | uses: s0/git-publish-subdir-action@develop 26 | env: 27 | REPO: self 28 | BRANCH: gh-pages 29 | FOLDER: website/dist 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Release 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: lts/* 21 | cache: yarn 22 | 23 | - run: yarn install --pure-lockfile 24 | - run: yarn typecheck 25 | - run: yarn test 26 | - run: yarn build 27 | 28 | - name: Publish 29 | run: | 30 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 31 | yarn publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | 12 | # misc 13 | .DS_Store 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Swan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @swan-io/use-form 2 | 3 | [![mit licence](https://img.shields.io/dub/l/vibe-d.svg?style=for-the-badge)](https://github.com/swan-io/use-form/blob/main/LICENSE) 4 | [![npm version](https://img.shields.io/npm/v/@swan-io/use-form?style=for-the-badge)](https://www.npmjs.org/package/@swan-io/use-form) 5 | [![bundlephobia](https://img.shields.io/bundlephobia/minzip/@swan-io/use-form?label=size&style=for-the-badge)](https://bundlephobia.com/result?p=@swan-io/use-form) 6 | 7 | A simple, fast, and opinionated form library for React & React Native focusing on UX.
8 | 👉 Take a look at [the demo website](https://swan-io.github.io/use-form). 9 | 10 | ## Setup 11 | 12 | ```bash 13 | $ npm install --save @swan-io/use-form 14 | # --- or --- 15 | $ yarn add @swan-io/use-form 16 | ``` 17 | 18 | ## Features 19 | 20 | - Subscription-based field updates (avoid re-render the whole form on each keystroke 🔥) 21 | - Validation strategies ✨ 22 | - Field sanitization 23 | - Mounted-only fields validation 24 | - Advanced focus handling 25 | - Best-in-class TypeScript support 26 | - Sync and async form submission 27 | 28 | ## Motivation 29 | 30 | Why another React form library 🤔?
31 | Because, as silly as it seems, we couldn't find any existing library which fits our existing needs: 32 | 33 | - We want validation strategies per field because we fell in love with them when we read the [re-formality](https://github.com/MinimaHQ/re-formality) documentation (which is unfortunately only available for [ReScript](https://rescript-lang.org/)). 34 | - It should be able to handle huge forms without a single performance hiccup. 35 | - Validation should be simple, reusable, and testable (aka just functions). 36 | - It shouldn't even try to validate unmounted fields. 37 | - It should have built-in focus management (to improve the keyboard flow of our React Native forms). 38 | 39 | ## Validation strategies ✨ 40 | 41 | The key of **good UX** is simple: validation should be executed **in continue**, feedback should be provided **when it makes sense**. 42 | 43 | ### Quick example: A credit card field 💳 44 | 45 | Let's say we want to display a valid state icon (✔) when the input value is a valid credit card number but don't want to display an error until the user blurs the field (and lets the value in an invalid state). 46 | 47 | #### Something like this: 48 | 49 | ![Valid credit card](docs/credit-card-valid.gif) 50 | ![Invalid credit card](docs/credit-card-error.gif) 51 | 52 | How do we easily achieve such magic? With the `onSuccessOrBlur` strategy 🧙‍♂️
53 | 54 | ```tsx 55 | const {} = useForm({ 56 | cardNumber: { initialValue: "", strategy: "onSuccessOrBlur" }, 57 | }); 58 | ``` 59 | 60 | Of course, `onSuccessOrBlur` will not fit perfectly every use-case!
61 | That's precisely why every field config could declare its own `strategy`: 62 | 63 | | Strategy | When feedback will be available? | 64 | | ----------------- | ------------------------------------------------------------- | 65 | | `onChange` | On first change (as the user types or update the value) | 66 | | `onSuccess` | On first validation success | 67 | | `onBlur` | On first field blur | 68 | | `onSuccessOrBlur` | On first validation success or first field blur **(default)** | 69 | | `onSubmit` | On form submit | 70 | 71 | #### Note that: 72 | 73 | - The strategies will only be activated after the field value update / the form submission. 74 | - Once the first feedback is given (the field is `valid` or should display an `error` message), the field switches to what we call _"talkative"_ state. After that, feedback will be updated on each value change until this field or the form is reset. 75 | 76 | ## API 77 | 78 | ⚠️ The API is described using TypeScript pseudocode.
These types are not exported by the library / are not even always valid. 79 | 80 | ### useForm 81 | 82 | `useForm` takes one argument (a map of your fields configs) and returns a set of helpers (functions, components, and values) to manage your form state. 83 | 84 | ```tsx 85 | import { useForm } from "@swan-io/use-form"; 86 | 87 | const { 88 | formStatus, 89 | Field, 90 | FieldsListener, 91 | getFieldValue, 92 | getFieldRef, 93 | setFieldValue, 94 | setFieldError, 95 | focusField, 96 | resetField, 97 | sanitizeField, 98 | validateField, 99 | listenFields, 100 | resetForm, 101 | submitForm, 102 | } = useForm({ 103 | // Keys are used as fields names 104 | fieldName: { 105 | initialValue: "", 106 | // Properties below are optional (those are the default values) 107 | strategy: "onSuccessOrBlur", 108 | isEqual: (value1, value2) => Object.is(value1, value2), 109 | sanitize: (value) => value, 110 | validate: (value, { focusField, getFieldValue }) => {}, 111 | }, 112 | }); 113 | ``` 114 | 115 | #### Field config 116 | 117 | ```tsx 118 | type fieldConfig = { 119 | // The initial field value. It could be anything (string, number, boolean…) 120 | initialValue: Value; 121 | 122 | // The chosen strategy. See "validation strategies" paragraph 123 | strategy: Strategy; 124 | 125 | // Used to perform initial and current value comparaison 126 | isEqual: (value1: Value, value2: Value) => boolean; 127 | 128 | // Will be run on value before validation and submission. Useful from trimming whitespaces 129 | sanitize: (value: Value) => Value; 130 | 131 | // Used to perform field validation. It could return an error message (or nothing) 132 | validate: (value: Value) => ErrorMessage | void; 133 | }; 134 | ``` 135 | 136 | #### formStatus 137 | 138 | ```tsx 139 | type formStatus = 140 | | "untouched" // no field has been updated 141 | | "editing" 142 | | "submitting" 143 | | "submitted"; 144 | ``` 145 | 146 | #### `` 147 | 148 | A component that exposes everything you need locally as a `children` render prop. 149 | 150 | ```tsx 151 | 152 | { 153 | (props: { 154 | // A ref to pass to your element (only required for focus handling) 155 | ref: MutableRefObject; 156 | // The field value 157 | value: Value; 158 | // Is the field valid? 159 | valid: boolean; 160 | // The field is invalid: here its error message. 161 | error?: ErrorMessage; 162 | // The onBlur handler (required for onBlur and onSuccessOrBlur strategies) 163 | onBlur: () => void; 164 | // The onChange handler (required) 165 | onChange: (value: Value) => void; 166 | }) => /* … */ 167 | } 168 | 169 | ``` 170 | 171 | #### `` 172 | 173 | A component that listens for fields states changes. It's useful when a part of your component needs to react to fields updates without triggering a full re-render. 174 | 175 | ```tsx 176 | 177 | { 178 | (states: Record<"firstName" | "lastName", { 179 | // The field value 180 | value: Value; 181 | // Is the field valid? 182 | valid: boolean; 183 | // The field is invalid: here its error message. 184 | error?: ErrorMessage; 185 | }>) => /* … */ 186 | } 187 | 188 | ``` 189 | 190 | #### getFieldValue 191 | 192 | By setting `sanitize: true`, you will enforce sanitization. 193 | 194 | ```tsx 195 | type getFieldValue = ( 196 | name: FieldName, 197 | options?: { 198 | sanitize?: boolean; 199 | }, 200 | ) => Value; 201 | ``` 202 | 203 | #### getFieldRef 204 | 205 | Return the field stable `ref`. 206 | 207 | ```tsx 208 | type getFieldRef = (name: FieldName) => MutableRefObject; 209 | ``` 210 | 211 | #### setFieldValue 212 | 213 | By setting `validate: true`, you will enforce validation. It has no effect if the field is already _talkative_. 214 | 215 | ```tsx 216 | type setFieldValue = ( 217 | name: FieldName, 218 | value: Value, 219 | options?: { 220 | validate?: boolean; 221 | }, 222 | ) => void; 223 | ``` 224 | 225 | #### setFieldError 226 | 227 | Will make the field _talkative_. 228 | 229 | ```tsx 230 | type setFieldError = (name: FieldName, error?: ErrorMessage) => void; 231 | ``` 232 | 233 | #### focusField 234 | 235 | Will only work if you forward the `Field` provided `ref` to your input. 236 | 237 | ```tsx 238 | type focusField = (name: FieldName) => void; 239 | ``` 240 | 241 | #### resetField 242 | 243 | Hide user feedback (the field is not _talkative_ anymore) and set value to `initialValue`. 244 | 245 | ```tsx 246 | type resetField = (name: FieldName) => void; 247 | ``` 248 | 249 | #### sanitizeField 250 | 251 | Sanitize the field value. 252 | 253 | ```tsx 254 | type sanitizeField = (name: FieldName) => void; 255 | ``` 256 | 257 | #### validateField 258 | 259 | Once you manually call validation, the field automatically switches to _talkative_ state. 260 | 261 | ```tsx 262 | type validateField = (name: FieldName) => ErrorMessage | void; 263 | ``` 264 | 265 | #### listenFields 266 | 267 | A function that listen for fields states changes. Useful when you want to apply side effects on values change. 268 | 269 | ```tsx 270 | React.useEffect(() => { 271 | const removeListener = listenFields( 272 | ["firstName", "lastName"], 273 | (states: Record<"firstName" | "lastName", { 274 | // The field value 275 | value: Value; 276 | // Is the field valid? 277 | valid: boolean; 278 | // The field is invalid: here its error message. 279 | error?: ErrorMessage; 280 | }>) => /* … */ 281 | ); 282 | 283 | return () => { 284 | removeListener(); 285 | } 286 | }, []); 287 | ``` 288 | 289 | #### resetForm 290 | 291 | Hide user feedback for all fields (they are not _talkative_ anymore). Reset values to their corresponding `initialValue` and `formStatus` to `untouched`. 292 | 293 | ```tsx 294 | type resetForm = () => void; 295 | ``` 296 | 297 | #### submitForm 298 | 299 | Submit your form. Each callback could return a `Promise` to keep `formStatus` in `submitting` state. 300 | 301 | ```tsx 302 | type submitForm = (options?: { 303 | onSuccess?: (values: OptionRecord) => Future | Promise | void; 304 | onFailure?: (errors: Partial>) => void; 305 | // by default, it will try to focus the first errored field (which is a good practice) 306 | focusOnFirstError?: boolean; 307 | }) => void; 308 | ``` 309 | 310 | ### combineValidators 311 | 312 | As it's a very common case to use several validation functions per field, we export a `combineValidators` helper function that allows you to chain sync validation functions: it will run them sequentially until an error is returned. 313 | 314 | ```tsx 315 | import { combineValidators, useForm } from "@swan-io/use-form"; 316 | 317 | const validateRequired = (value: string) => { 318 | if (!value) { 319 | return "required"; 320 | } 321 | }; 322 | 323 | const validateEmail = (email: string) => { 324 | if (!/.+@.+\..{2,}/.test(email)) { 325 | return "invalid email"; 326 | } 327 | }; 328 | 329 | const MyAwesomeForm = () => { 330 | const { Field, submitForm } = useForm({ 331 | emailAddress: { 332 | initialValue: "", 333 | // will run each validation function until an error is returned 334 | validate: combineValidators( 335 | isEmailRequired && validateRequired, // validation checks could be applied conditionally 336 | validateEmail, 337 | ), 338 | }, 339 | }); 340 | 341 | // … 342 | }; 343 | ``` 344 | 345 | ### toOptionalValidator 346 | 347 | Very often, we want to execute validation only if a value is not empty. By wrapping any validator (or combined validators) with `toOptionalValidator`, you can bypass the validation in such cases. 348 | 349 | ```tsx 350 | import { toOptionalValidator, Validator } from "@swan-io/use-form"; 351 | 352 | // This validator will error if the string length is < 3 (even if it's an empty string) 353 | const validator: Validator = (value) => { 354 | if (value.length < 3) { 355 | return "Must be at least 3 characters"; 356 | } 357 | }; 358 | 359 | // This validator will error if value is not empty string and if the string length is < 3 360 | const optionalValidator = toOptionalValidator(validator); 361 | ``` 362 | 363 | This function also accept a second param (required for non-string validators) to specify what is an empty value. 364 | 365 | ```tsx 366 | import { toOptionalValidator, Validator } from "@swan-io/use-form"; 367 | 368 | const validator: Validator = (value) => { 369 | if (value < 10) { 370 | return "Must pick at least 10 items"; 371 | } 372 | }; 373 | 374 | // This validator will also accept a value of 0, as we consider it "empty" 375 | const optionalValidator = toOptionalValidator(validator, (value) => value === 0); 376 | ``` 377 | 378 | ## Quickstart 379 | 380 | ```tsx 381 | import { useForm } from "@swan-io/use-form"; 382 | 383 | const MyAwesomeForm = () => { 384 | const { Field, submitForm } = useForm({ 385 | firstName: { 386 | initialValue: "", 387 | strategy: "onSuccessOrBlur", 388 | sanitize: (value) => value.trim(), // we trim value before validation and submission 389 | validate: (value) => { 390 | if (value === "") { 391 | return "First name is required"; 392 | } 393 | }, 394 | }, 395 | }); 396 | 397 | return ( 398 |
{ 400 | event.preventDefault(); 401 | 402 | submitForm({ 403 | onSuccess: (values) => console.log("values", values), // all fields are valid 404 | onFailure: (errors) => console.log("errors", errors), // at least one field is invalid 405 | }); 406 | }} 407 | > 408 | 409 | {({ error, onBlur, onChange, valid, value }) => ( 410 | <> 411 | 412 | 413 | { 418 | onChange(target.value); 419 | }} 420 | /> 421 | 422 | {valid && Valid} 423 | {error && Invalid} 424 | 425 | )} 426 | 427 | 428 | 429 |
430 | ); 431 | }; 432 | ``` 433 | 434 | ## More examples 435 | 436 | A full set of examples is available on [the demo website](https://swan-io.github.io/use-form) or in the [`/website` directory](https://github.com/swan-io/use-form/tree/main/website) project. Just clone the repository, install its dependencies and start it! 437 | 438 | ## Acknowledgements 439 | 440 | - [re-formality](https://github.com/MinimaHQ/re-formality) for the [validation strategies](https://github.com/MinimaHQ/re-formality/blob/master/docs/02-ValidationStrategies.md) idea. 441 | - [react-hook-form](https://react-hook-form.com/) and [react-final-form](https://github.com/final-form/react-final-form) for their subscription pattern implementations. 442 | -------------------------------------------------------------------------------- /__tests__/combineValidators.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { combineValidators } from "../src"; 3 | 4 | const validateRequired = (value: string) => { 5 | if (!value) { 6 | return "required"; 7 | } 8 | }; 9 | 10 | const validateMinLength = (minLength: number) => (value: string) => { 11 | if (value.length < minLength) { 12 | return "too short value"; 13 | } 14 | }; 15 | 16 | const validateEmail = (email: string) => { 17 | if (!/.+@.+\..{2,}/.test(email)) { 18 | return "invalid email"; 19 | } 20 | }; 21 | 22 | test("combine required and min length sync validations", () => { 23 | const validate = combineValidators(validateRequired, validateMinLength(6)); 24 | 25 | const input1 = ""; 26 | const expected1 = "required"; 27 | const output1 = validate(input1); 28 | 29 | const input2 = "hello"; 30 | const expected2 = "too short value"; 31 | const output2 = validate(input2); 32 | 33 | const input3 = "hello world"; 34 | const expected3 = undefined; 35 | const output3 = validate(input3); 36 | 37 | expect(output1).toBe(expected1); 38 | expect(output2).toBe(expected2); 39 | expect(output3).toBe(expected3); 40 | }); 41 | 42 | test("combine min length and email sync validations", () => { 43 | const validate = combineValidators(validateMinLength(6), validateEmail); 44 | 45 | const input1 = "hello"; 46 | const expected1 = "too short value"; 47 | const output1 = validate(input1); 48 | 49 | const input2 = "hello@swan"; 50 | const expected2 = "invalid email"; 51 | const output2 = validate(input2); 52 | 53 | const input3 = "hello@swan.io"; 54 | const expected3 = undefined; 55 | const output3 = validate(input3); 56 | 57 | expect(output1).toBe(expected1); 58 | expect(output2).toBe(expected2); 59 | expect(output3).toBe(expected3); 60 | }); 61 | -------------------------------------------------------------------------------- /__tests__/errors.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | test("calling setError set a field error", async () => { 7 | const Test = () => { 8 | const { Field, setFieldError } = useForm({ 9 | field: { 10 | initialValue: "", 11 | }, 12 | }); 13 | 14 | return ( 15 |
e.preventDefault()}> 16 | 17 | {({ error }) => (error ?
{error}
:
no error
)} 18 |
19 | 20 | 27 |
28 | ); 29 | }; 30 | 31 | render(); 32 | 33 | const setErrorButton = await screen.findByText("Set error"); 34 | 35 | await screen.findByText("no error"); 36 | fireEvent.click(setErrorButton); 37 | await screen.findByText("now with an error"); 38 | }); 39 | -------------------------------------------------------------------------------- /__tests__/fieldsListener.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { expect, test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | test("FieldsListener is synchronized with fields states", async () => { 7 | let viewerRenderCount = 0; 8 | let formRenderCount = 0; 9 | 10 | const FirstNameViewer = ({ 11 | value, 12 | valid, 13 | error, 14 | }: { 15 | value: string; 16 | valid: boolean; 17 | error?: string; 18 | }) => { 19 | viewerRenderCount++; 20 | 21 | return ( 22 | <> 23 |
value: {value}
24 | {!(valid || error) &&
idle
} 25 | {valid &&
valid
} 26 | {error &&
error
} 27 | 28 | ); 29 | }; 30 | 31 | const Test = () => { 32 | formRenderCount++; 33 | 34 | const { Field, FieldsListener, resetForm } = useForm({ 35 | firstName: { 36 | strategy: "onChange", 37 | initialValue: "", 38 | validate: (value) => { 39 | if (value.length < 3) { 40 | return "Must be at least 3 characters"; 41 | } 42 | }, 43 | }, 44 | lastName: { 45 | strategy: "onChange", 46 | initialValue: "", 47 | validate: (value) => { 48 | if (value.length < 3) { 49 | return "Must be at least 3 characters"; 50 | } 51 | }, 52 | }, 53 | }); 54 | 55 | return ( 56 |
e.preventDefault()}> 57 | 58 | {({ value, onBlur, onChange }) => ( 59 | <> 60 | 61 | 62 | { 68 | e.preventDefault(); 69 | onChange(e.target.value); 70 | }} 71 | /> 72 | 73 | )} 74 | 75 | 76 | 77 | {({ firstName }) => } 78 | 79 | 80 | 81 | {({ value, onBlur, onChange }) => ( 82 | <> 83 | 84 | 85 | { 91 | e.preventDefault(); 92 | onChange(e.target.value); 93 | }} 94 | /> 95 | 96 | )} 97 | 98 | 99 | 100 |
101 | ); 102 | }; 103 | 104 | render(); 105 | 106 | expect(formRenderCount).toEqual(1); 107 | expect(viewerRenderCount).toEqual(1); 108 | 109 | const firstNameInput = await screen.findByLabelText("First name"); 110 | const lastNameInput = await screen.findByLabelText("Last name"); 111 | 112 | // fireEvent doesn't simulate typing, so viewerRenderCount will only be increased by 1 113 | fireEvent.input(firstNameInput, { target: { value: "Ni" } }); 114 | 115 | await screen.findByText("value: Ni"); 116 | await screen.findByText("error"); 117 | 118 | expect(formRenderCount).toEqual(2); 119 | expect(viewerRenderCount).toEqual(2); 120 | 121 | fireEvent.input(firstNameInput, { target: { value: "Nicolas" } }); 122 | 123 | expect(formRenderCount).toEqual(2); 124 | expect(viewerRenderCount).toEqual(3); 125 | 126 | await screen.findByText("value: Nicolas"); 127 | await screen.findByText("valid"); 128 | 129 | fireEvent.input(lastNameInput, { target: { value: "Saison" } }); 130 | 131 | expect(formRenderCount).toEqual(2); 132 | expect(viewerRenderCount).toEqual(3); 133 | }); 134 | -------------------------------------------------------------------------------- /__tests__/focusHandling.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { expect, test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | test("the first errored field is focused after submission", async () => { 7 | const Test = () => { 8 | const { Field, resetForm, submitForm } = useForm({ 9 | firstName: { 10 | strategy: "onSubmit", 11 | initialValue: "", 12 | validate: (value) => { 13 | if (value.length < 3) { 14 | return "Must be at least 3 characters"; 15 | } 16 | }, 17 | }, 18 | lastName: { 19 | strategy: "onSubmit", 20 | initialValue: "", 21 | validate: (value) => { 22 | if (value.length < 3) { 23 | return "Must be at least 3 characters"; 24 | } 25 | }, 26 | }, 27 | }); 28 | 29 | return ( 30 |
e.preventDefault()}> 31 | 32 | {({ ref, error, onBlur, onChange, valid, value }) => ( 33 | <> 34 | 35 | 36 | { 43 | e.preventDefault(); 44 | onChange(e.target.value); 45 | }} 46 | /> 47 | 48 | {!(valid || error) &&
firstName idle
} 49 | {valid &&
firstName valid
} 50 | {error &&
firstName error
} 51 | 52 | )} 53 |
54 | 55 | 56 | {({ ref, error, onBlur, onChange, valid, value }) => ( 57 | <> 58 | 59 | 60 | { 67 | e.preventDefault(); 68 | onChange(e.target.value); 69 | }} 70 | /> 71 | 72 | {!(valid || error) &&
lastName idle
} 73 | {valid &&
lastName valid
} 74 | {error &&
lastName error
} 75 | 76 | )} 77 |
78 | 79 | 80 | 81 |
82 | ); 83 | }; 84 | 85 | render(); 86 | 87 | const firstNameInput = await screen.findByLabelText("First name"); 88 | const lastNameInput = await screen.findByLabelText("Last name"); 89 | 90 | const submitButton = await screen.findByText("Submit"); 91 | 92 | fireEvent.input(firstNameInput, { target: { value: "Nicolas" } }); 93 | fireEvent.input(lastNameInput, { target: { value: "Ni" } }); 94 | 95 | fireEvent.click(submitButton); 96 | 97 | await screen.findByText("firstName valid"); 98 | await screen.findByText("lastName error"); 99 | 100 | expect(document.activeElement).toBe(lastNameInput); 101 | }); 102 | 103 | test("the user can disable autofocus on first error", async () => { 104 | const Test = () => { 105 | const { Field, submitForm } = useForm({ 106 | firstName: { 107 | initialValue: "", 108 | validate: (value) => { 109 | if (value.length < 3) { 110 | return "Must be at least 3 characters"; 111 | } 112 | }, 113 | }, 114 | }); 115 | 116 | return ( 117 |
e.preventDefault()}> 118 | 119 | {({ ref, error, onBlur, onChange, valid, value }) => ( 120 | <> 121 | 122 | 123 | { 130 | e.preventDefault(); 131 | onChange(e.target.value); 132 | }} 133 | /> 134 | 135 | {!(valid || error) &&
idle
} 136 | {valid &&
valid
} 137 | {error &&
error
} 138 | 139 | )} 140 |
141 | 142 | 143 |
144 | ); 145 | }; 146 | 147 | render(); 148 | 149 | const input = await screen.findByLabelText("First name"); 150 | const submitButton = await screen.findByText("Submit"); 151 | 152 | fireEvent.input(input, { target: { value: "Ni" } }); 153 | fireEvent.click(submitButton); 154 | 155 | await screen.findByText("error"); 156 | 157 | expect(document.activeElement).not.toBe(input); 158 | }); 159 | 160 | test("focusField behave like expected", async () => { 161 | const Test = () => { 162 | const { Field, focusField } = useForm({ 163 | firstName: { initialValue: "" }, 164 | lastName: { initialValue: "" }, 165 | }); 166 | 167 | return ( 168 |
e.preventDefault()}> 169 | 170 | {({ ref, error, onBlur, onChange, valid, value }) => ( 171 | <> 172 | 173 | 174 | { 181 | e.preventDefault(); 182 | const { value } = e.target; 183 | onChange(value); 184 | 185 | if (value.length > 3) { 186 | focusField("lastName"); 187 | } 188 | }} 189 | /> 190 | 191 | {!(valid || error) &&
firstName idle
} 192 | {valid &&
firstName valid
} 193 | {error &&
firstName error
} 194 | 195 | )} 196 |
197 | 198 | 199 | {({ ref, error, onBlur, onChange, valid, value }) => ( 200 | <> 201 | 202 | 203 | { 210 | e.preventDefault(); 211 | onChange(e.target.value); 212 | }} 213 | /> 214 | 215 | {!(valid || error) &&
lastName idle
} 216 | {valid &&
lastName valid
} 217 | {error &&
lastName error
} 218 | 219 | )} 220 |
221 | 222 | 223 |
224 | ); 225 | }; 226 | 227 | render(); 228 | 229 | const firstNameInput = await screen.findByLabelText("First name"); 230 | const lastNameInput = await screen.findByLabelText("Last name"); 231 | 232 | const focusFirstNameButton = await screen.findByText("Focus firstName"); 233 | 234 | fireEvent.click(focusFirstNameButton); 235 | expect(document.activeElement).toBe(firstNameInput); 236 | 237 | fireEvent.input(firstNameInput, { target: { value: "Nicolas" } }); 238 | expect(document.activeElement).toBe(lastNameInput); 239 | }); 240 | -------------------------------------------------------------------------------- /__tests__/formStatus.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { test } from "vitest"; 4 | import { useForm } from "../src"; 5 | import { resolveAfter } from "./utils/promises"; 6 | 7 | test("formStatus evolve though time", async () => { 8 | const Test = () => { 9 | const { Field, formStatus, resetForm, submitForm } = useForm({ 10 | firstName: { 11 | strategy: "onSuccess", 12 | initialValue: "", 13 | validate: (value) => { 14 | if (value.length < 3) { 15 | return "Must be at least 3 characters"; 16 | } 17 | }, 18 | }, 19 | }); 20 | 21 | return ( 22 |
e.preventDefault()}> 23 | 24 | {({ error, onBlur, onChange, valid, value }) => ( 25 | <> 26 | 27 | 28 | { 34 | e.preventDefault(); 35 | onChange(e.target.value); 36 | }} 37 | /> 38 | 39 | {!(valid || error) &&
idle
} 40 | {valid &&
valid
} 41 | {error &&
error
} 42 | 43 | )} 44 |
45 | 46 |
formStatus: {formStatus}
47 | 48 | 49 | 50 |
51 | ); 52 | }; 53 | 54 | render(); 55 | 56 | const input = await screen.findByLabelText("First name"); 57 | const resetButton = await screen.findByText("Reset"); 58 | const submitButton = await screen.findByText("Submit"); 59 | 60 | await screen.findByText("formStatus: untouched"); 61 | 62 | fireEvent.input(input, { 63 | target: { value: "Nicolas" }, 64 | }); 65 | 66 | await screen.findByText("formStatus: editing"); 67 | fireEvent.click(submitButton); 68 | await screen.findByText("formStatus: submitted"); 69 | fireEvent.click(resetButton); 70 | await screen.findByText("formStatus: untouched"); 71 | }); 72 | 73 | test("formStatus evolve though time with async submission", async () => { 74 | const Test = () => { 75 | const { Field, formStatus, resetForm, submitForm } = useForm({ 76 | firstName: { 77 | strategy: "onSuccess", 78 | initialValue: "", 79 | validate: (value) => { 80 | if (value.length < 3) { 81 | return "Must be at least 3 characters"; 82 | } 83 | }, 84 | }, 85 | }); 86 | 87 | return ( 88 |
e.preventDefault()}> 89 | 90 | {({ error, onBlur, onChange, valid, value }) => ( 91 | <> 92 | 93 | 94 | { 100 | e.preventDefault(); 101 | onChange(e.target.value); 102 | }} 103 | /> 104 | 105 | {!(valid || error) &&
idle
} 106 | {valid &&
valid
} 107 | {error &&
error
} 108 | 109 | )} 110 |
111 | 112 |
formStatus: {formStatus}
113 | 114 | 115 | 116 | 117 |
118 | ); 119 | }; 120 | 121 | render(); 122 | 123 | const input = await screen.findByLabelText("First name"); 124 | const resetButton = await screen.findByText("Reset"); 125 | const submitButton = await screen.findByText("Submit"); 126 | 127 | await screen.findByText("formStatus: untouched"); 128 | 129 | fireEvent.input(input, { 130 | target: { value: "Nicolas" }, 131 | }); 132 | 133 | await screen.findByText("formStatus: editing"); 134 | fireEvent.click(submitButton); 135 | await screen.findByText("formStatus: submitting"); 136 | await screen.findByText("formStatus: submitted"); 137 | fireEvent.click(resetButton); 138 | await screen.findByText("formStatus: untouched"); 139 | }); 140 | -------------------------------------------------------------------------------- /__tests__/listenFields.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { expect, test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | test("Count the number of updates", async () => { 7 | let nameUpdateCount = 0; 8 | 9 | const Test = () => { 10 | const { Field, listenFields } = useForm({ 11 | firstName: { 12 | strategy: "onChange", 13 | initialValue: "", 14 | }, 15 | lastName: { 16 | strategy: "onChange", 17 | initialValue: "", 18 | }, 19 | }); 20 | 21 | const [fullName, setFullName] = React.useState(""); 22 | 23 | React.useEffect(() => { 24 | const removeListener = listenFields(["firstName", "lastName"], ({ firstName, lastName }) => { 25 | nameUpdateCount++; 26 | setFullName([firstName.value, lastName.value].filter(Boolean).join(" ")); 27 | }); 28 | 29 | return () => { 30 | removeListener(); 31 | }; 32 | }, [listenFields]); 33 | 34 | return ( 35 |
e.preventDefault()}> 36 | 37 | {({ value, onBlur, onChange }) => ( 38 | <> 39 | 40 | 41 | { 47 | e.preventDefault(); 48 | onChange(e.target.value); 49 | }} 50 | /> 51 | 52 | )} 53 | 54 | 55 | 56 | {({ value, onBlur, onChange }) => ( 57 | <> 58 | 59 | 60 | { 66 | e.preventDefault(); 67 | onChange(e.target.value); 68 | }} 69 | /> 70 | 71 | )} 72 | 73 | 74 |
value: {fullName}
75 |
76 | ); 77 | }; 78 | 79 | render(); 80 | 81 | expect(nameUpdateCount).toEqual(0); 82 | 83 | const firstNameInput = await screen.findByLabelText("First name"); 84 | const lastNameInput = await screen.findByLabelText("Last name"); 85 | 86 | // fireEvent doesn't simulate typing, so viewerRenderCount will only be increased by 1 87 | fireEvent.input(firstNameInput, { target: { value: "Ni" } }); 88 | 89 | expect(nameUpdateCount).toEqual(1); 90 | 91 | await screen.findByText("value: Ni"); 92 | 93 | fireEvent.input(firstNameInput, { target: { value: "Nicolas" } }); 94 | 95 | expect(nameUpdateCount).toEqual(2); 96 | 97 | await screen.findByText("value: Nicolas"); 98 | 99 | fireEvent.input(lastNameInput, { target: { value: "Saison" } }); 100 | 101 | expect(nameUpdateCount).toEqual(3); 102 | 103 | await screen.findByText("value: Nicolas Saison"); 104 | }); 105 | -------------------------------------------------------------------------------- /__tests__/sanitization.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | test("input sanitization is perform onBlur", async () => { 7 | const Test = () => { 8 | const { Field, sanitizeField } = useForm({ 9 | firstName: { 10 | initialValue: "", 11 | sanitize: (value) => value.trim(), 12 | }, 13 | }); 14 | 15 | return ( 16 |
e.preventDefault()}> 17 | 18 | {({ ref, onBlur, onChange, value }) => ( 19 | <> 20 | 21 | 22 | { 28 | sanitizeField("firstName"); 29 | onBlur(); 30 | }} 31 | onChange={(e) => { 32 | e.preventDefault(); 33 | onChange(e.target.value); 34 | }} 35 | /> 36 | 37 | Sanitized value: "{value}" 38 | 39 | )} 40 | 41 |
42 | ); 43 | }; 44 | 45 | render(); 46 | 47 | const input = await screen.findByLabelText("First name"); 48 | 49 | fireEvent.focus(input); 50 | fireEvent.input(input, { target: { value: " Nicolas " } }); 51 | fireEvent.blur(input); 52 | 53 | await screen.findByText('Sanitized value: "Nicolas"'); 54 | }); 55 | -------------------------------------------------------------------------------- /__tests__/strategies.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | const sanitize = (value: string) => value.trim(); 7 | const validate = (value: string) => { 8 | if (value.length < 3) { 9 | return "Must be at least 3 characters"; 10 | } 11 | }; 12 | 13 | test("validation strategies give feedback at the right time", async () => { 14 | const Test = () => { 15 | const { Field, resetForm, submitForm } = useForm({ 16 | onChange: { 17 | strategy: "onChange", 18 | initialValue: "", 19 | sanitize, 20 | validate, 21 | }, 22 | onSuccess: { 23 | strategy: "onSuccess", 24 | initialValue: "", 25 | sanitize, 26 | validate, 27 | }, 28 | onBlur: { 29 | strategy: "onBlur", 30 | initialValue: "", 31 | sanitize, 32 | validate, 33 | }, 34 | onSuccessOrBlur: { 35 | strategy: "onSuccessOrBlur", 36 | initialValue: "", 37 | sanitize, 38 | validate, 39 | }, 40 | onSubmit: { 41 | strategy: "onSubmit", 42 | initialValue: "", 43 | sanitize, 44 | validate, 45 | }, 46 | }); 47 | 48 | return ( 49 |
e.preventDefault()}> 50 | 51 | {({ error, onBlur, onChange, valid, value }) => ( 52 | <> 53 | 54 | 55 | { 61 | e.preventDefault(); 62 | onChange(e.target.value); 63 | }} 64 | /> 65 | 66 | {!(valid || error) &&
onChange idle
} 67 | {valid &&
onChange valid
} 68 | {error &&
onChange error
} 69 | 70 | )} 71 |
72 | 73 | 74 | {({ error, onBlur, onChange, valid, value }) => ( 75 | <> 76 | 77 | 78 | { 84 | e.preventDefault(); 85 | onChange(e.target.value); 86 | }} 87 | /> 88 | 89 | {!(valid || error) &&
onSuccess idle
} 90 | {valid &&
onSuccess valid
} 91 | {error &&
onSuccess error
} 92 | 93 | )} 94 |
95 | 96 | 97 | {({ error, onBlur, onChange, valid, value }) => ( 98 | <> 99 | 100 | 101 | { 107 | e.preventDefault(); 108 | onChange(e.target.value); 109 | }} 110 | /> 111 | 112 | {!(valid || error) &&
onBlur idle
} 113 | {valid &&
onBlur valid
} 114 | {error &&
onBlur error
} 115 | 116 | )} 117 |
118 | 119 | 120 | {({ error, onBlur, onChange, valid, value }) => ( 121 | <> 122 | 123 | 124 | { 130 | e.preventDefault(); 131 | onChange(e.target.value); 132 | }} 133 | /> 134 | 135 | {!(valid || error) &&
onSuccessOrBlur idle
} 136 | {valid &&
onSuccessOrBlur valid
} 137 | {error &&
onSuccessOrBlur error
} 138 | 139 | )} 140 |
141 | 142 | 143 | {({ error, onBlur, onChange, valid, value }) => ( 144 | <> 145 | 146 | 147 | { 153 | e.preventDefault(); 154 | onChange(e.target.value); 155 | }} 156 | /> 157 | 158 | {!(valid || error) &&
onSubmit idle
} 159 | {valid &&
onSubmit valid
} 160 | {error &&
onSubmit error
} 161 | 162 | )} 163 |
164 | 165 | 166 | 167 |
168 | ); 169 | }; 170 | 171 | render(); 172 | 173 | let input = await screen.findByLabelText("onChange"); 174 | const resetButton = await screen.findByText("Reset"); 175 | const submitButton = await screen.findByText("Submit"); 176 | 177 | await screen.findByText("onChange idle"); 178 | fireEvent.input(input, { target: { value: "Ni" } }); 179 | await screen.findByText("onChange error"); 180 | fireEvent.input(input, { target: { value: "Nicolas" } }); 181 | await screen.findByText("onChange valid"); 182 | fireEvent.input(input, { target: { value: "Ni" } }); 183 | await screen.findByText("onChange error"); 184 | 185 | fireEvent.click(resetButton); 186 | input = await screen.findByLabelText("onSuccess"); 187 | 188 | await screen.findByText("onSuccess idle"); 189 | fireEvent.input(input, { target: { value: "Ni" } }); 190 | await screen.findByText("onSuccess idle"); 191 | fireEvent.input(input, { target: { value: "Nicolas" } }); 192 | await screen.findByText("onSuccess valid"); 193 | fireEvent.input(input, { target: { value: "Ni" } }); 194 | await screen.findByText("onSuccess error"); 195 | 196 | fireEvent.click(resetButton); 197 | input = await screen.findByLabelText("onBlur"); 198 | 199 | await screen.findByText("onBlur idle"); 200 | fireEvent.input(input, { target: { value: "Ni" } }); 201 | await screen.findByText("onBlur idle"); 202 | fireEvent.input(input, { target: { value: "Nicolas" } }); 203 | await screen.findByText("onBlur idle"); 204 | fireEvent.blur(input); 205 | await screen.findByText("onBlur valid"); 206 | fireEvent.input(input, { target: { value: "Ni" } }); 207 | await screen.findByText("onBlur error"); 208 | 209 | fireEvent.click(resetButton); 210 | input = await screen.findByLabelText("onSuccessOrBlur"); 211 | 212 | await screen.findByText("onSuccessOrBlur idle"); 213 | fireEvent.input(input, { target: { value: "Ni" } }); 214 | await screen.findByText("onSuccessOrBlur idle"); 215 | fireEvent.input(input, { target: { value: "Nicolas" } }); 216 | await screen.findByText("onSuccessOrBlur valid"); 217 | fireEvent.input(input, { target: { value: "Ni" } }); 218 | await screen.findByText("onSuccessOrBlur error"); 219 | 220 | fireEvent.click(resetButton); 221 | 222 | await screen.findByText("onSuccessOrBlur idle"); 223 | fireEvent.input(input, { target: { value: "Ni" } }); 224 | await screen.findByText("onSuccessOrBlur idle"); 225 | fireEvent.blur(input); 226 | await screen.findByText("onSuccessOrBlur error"); 227 | 228 | fireEvent.click(resetButton); 229 | input = await screen.findByLabelText("onSubmit"); 230 | 231 | await screen.findByText("onSubmit idle"); 232 | fireEvent.input(input, { target: { value: "Ni" } }); 233 | await screen.findByText("onSubmit idle"); 234 | fireEvent.input(input, { target: { value: "Nicolas" } }); 235 | await screen.findByText("onSubmit idle"); 236 | fireEvent.click(submitButton); 237 | await screen.findByText("onSubmit valid"); 238 | fireEvent.input(input, { target: { value: "Ni" } }); 239 | await screen.findByText("onSubmit error"); 240 | }); 241 | -------------------------------------------------------------------------------- /__tests__/toOptionalValidator.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { Validator, toOptionalValidator } from "../src"; 3 | 4 | test("sync validator to optional validator", () => { 5 | const error = "Must be at least 3 characters"; 6 | 7 | const validator: Validator = (value) => { 8 | if (value.length < 3) { 9 | return error; 10 | } 11 | }; 12 | 13 | expect(validator("")).toBe(error); 14 | expect(validator("x")).toBe(error); 15 | expect(validator("Michel")).not.toBeDefined(); 16 | 17 | const optionalValidator = toOptionalValidator(validator); 18 | 19 | expect(optionalValidator("")).not.toBeDefined(); 20 | expect(optionalValidator("x")).toBe(error); 21 | expect(optionalValidator("Michel")).not.toBeDefined(); 22 | }); 23 | 24 | test("sync validator to optional validator (without custom empty value)", () => { 25 | const error = "Must be at least 3 characters"; 26 | 27 | const validator: Validator = (value) => (value.length < 3 ? error : undefined); 28 | 29 | expect(validator("")).toBe(error); 30 | expect(validator("x")).toBe(error); 31 | expect(validator("Michel")).not.toBeDefined(); 32 | 33 | const optionalValidator = toOptionalValidator(validator); 34 | 35 | expect(optionalValidator("")).not.toBeDefined(); 36 | expect(optionalValidator("x")).toBe(error); 37 | expect(optionalValidator("Michel")).not.toBeDefined(); 38 | }); 39 | 40 | test("sync validator to optional validator (with custom empty value)", () => { 41 | const error = "Number must be a position multiple of 2"; 42 | 43 | const validator: Validator = (value) => 44 | value <= 0 || value % 2 !== 0 ? error : undefined; 45 | 46 | expect(validator(0)).toBe(error); 47 | expect(validator(1)).toBe(error); 48 | expect(validator(2)).not.toBeDefined(); 49 | 50 | const optionalValidator = toOptionalValidator(validator, (value) => value === 0); 51 | 52 | expect(optionalValidator(0)).not.toBeDefined(); 53 | expect(optionalValidator(1)).toBe(error); 54 | expect(optionalValidator(2)).not.toBeDefined(); 55 | }); 56 | -------------------------------------------------------------------------------- /__tests__/utils/promises.ts: -------------------------------------------------------------------------------- 1 | export const resolveAfter = (delay: number, value?: T): Promise => 2 | new Promise((resolve) => setTimeout(() => resolve(value), delay)); 3 | 4 | export const rejectAfter = (delay: number, value?: T): Promise => 5 | new Promise((_, reject) => setTimeout(() => reject(value), delay)); 6 | -------------------------------------------------------------------------------- /__tests__/validation.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { test } from "vitest"; 4 | import { useForm } from "../src"; 5 | 6 | test("input validation evolve though time", async () => { 7 | const Test = () => { 8 | const { Field, resetForm, submitForm } = useForm({ 9 | firstName: { 10 | strategy: "onBlur", 11 | initialValue: "", 12 | validate: (value) => { 13 | if (value.length < 3) { 14 | return "Must be at least 3 characters"; 15 | } 16 | }, 17 | }, 18 | }); 19 | 20 | return ( 21 |
e.preventDefault()}> 22 | 23 | {({ ref, error, onBlur, onChange, valid, value }) => ( 24 | <> 25 | 26 | 27 | { 34 | e.preventDefault(); 35 | onChange(e.target.value); 36 | }} 37 | /> 38 | 39 | {!(valid || error) &&
idle
} 40 | {valid &&
valid
} 41 | {error &&
error
} 42 | 43 | )} 44 |
45 | 46 | 47 | 48 |
49 | ); 50 | }; 51 | 52 | render(); 53 | 54 | const input = await screen.findByLabelText("First name"); 55 | 56 | fireEvent.focus(input); 57 | fireEvent.input(input, { target: { value: "Ni" } }); 58 | fireEvent.blur(input); 59 | 60 | await screen.findByText("error"); 61 | 62 | fireEvent.input(input, { target: { value: "Nicolas" } }); 63 | 64 | await screen.findByText("valid"); 65 | }); 66 | -------------------------------------------------------------------------------- /docs/credit-card-error.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/use-form/21ee09e73457d695d4cdd3651b4cacbcc865c68d/docs/credit-card-error.gif -------------------------------------------------------------------------------- /docs/credit-card-valid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/use-form/21ee09e73457d695d4cdd3651b4cacbcc865c68d/docs/credit-card-valid.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swan-io/use-form", 3 | "version": "3.1.0", 4 | "license": "MIT", 5 | "description": "A simple, fast and opinionated form library for React & React Native focusing on UX.", 6 | "author": "Mathieu Acthernoene ", 7 | "contributors": [ 8 | "Frederic Godin " 9 | ], 10 | "homepage": "https://github.com/swan-io/use-form#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/swan-io/use-form.git" 14 | }, 15 | "sideEffects": false, 16 | "source": "src/index.ts", 17 | "main": "dist/index.js", 18 | "module": "dist/index.mjs", 19 | "types": "dist/index.d.ts", 20 | "files": [ 21 | "dist" 22 | ], 23 | "keywords": [ 24 | "form", 25 | "hook", 26 | "react", 27 | "typescript", 28 | "ux", 29 | "validation" 30 | ], 31 | "publishConfig": { 32 | "access": "public", 33 | "registry": "https://registry.npmjs.org" 34 | }, 35 | "scripts": { 36 | "format": "prettier '**/*' --ignore-unknown --write", 37 | "lint": "eslint --ext ts,tsx __tests__ src", 38 | "test": "vitest --run", 39 | "test:watch": "vitest --watch", 40 | "typecheck": "tsc --noEmit", 41 | "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", 42 | "prepack": "yarn typecheck && yarn lint && yarn test && yarn build" 43 | }, 44 | "prettier": { 45 | "printWidth": 100, 46 | "plugins": [ 47 | "prettier-plugin-organize-imports" 48 | ] 49 | }, 50 | "peerDependencies": { 51 | "react": ">=18.0.0" 52 | }, 53 | "dependencies": { 54 | "@swan-io/boxed": "^3.0.0" 55 | }, 56 | "devDependencies": { 57 | "@testing-library/react": "^14.2.2", 58 | "@types/node": "^20.12.4", 59 | "@types/react": "^18.2.74", 60 | "@typescript-eslint/eslint-plugin": "^7.5.0", 61 | "@typescript-eslint/parser": "^7.5.0", 62 | "eslint": "^8.57.0", 63 | "eslint-plugin-react-hooks": "^4.6.0", 64 | "jsdom": "^24.0.0", 65 | "prettier": "^3.2.5", 66 | "prettier-plugin-organize-imports": "^3.2.4", 67 | "react": "^18.2.0", 68 | "react-dom": "^18.2.0", 69 | "tsup": "^8.0.2", 70 | "typescript": "^5.4.3", 71 | "vitest": "^1.4.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/combineValidators.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from "./types"; 2 | 3 | export const combineValidators = 4 | ( 5 | ...validators: (Validator | false)[] 6 | ): Validator => 7 | (value) => { 8 | const [validator, ...nextValidators] = validators; 9 | 10 | if (validator != null && validator !== false) { 11 | const result = validator(value); 12 | 13 | if (typeof result !== "undefined") { 14 | return result; 15 | } 16 | } 17 | 18 | if (nextValidators.length > 0) { 19 | return combineValidators(...nextValidators)(value); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export const identity = (value: T) => value; 2 | export const noop = () => {}; 3 | export const isEmptyString = (value: unknown) => value === ""; 4 | 5 | export const isPromise = (value: unknown): value is Promise => 6 | !!value && 7 | (typeof value === "object" || typeof value === "function") && 8 | typeof (value as { then?: () => void }).then === "function"; 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { combineValidators } from "./combineValidators"; 2 | export { toOptionalValidator } from "./toOptionalValidator"; 3 | export { useForm } from "./useForm"; 4 | 5 | export type { 6 | FieldState, 7 | Form, 8 | FormConfig, 9 | FormStatus, 10 | OptionRecord, 11 | Strategy, 12 | Validator, 13 | ValidatorResult, 14 | } from "./types"; 15 | -------------------------------------------------------------------------------- /src/toOptionalValidator.ts: -------------------------------------------------------------------------------- 1 | import { isEmptyString } from "./helpers"; 2 | import { Validator } from "./types"; 3 | 4 | export const toOptionalValidator = 5 | ( 6 | validator: Validator, 7 | ...args: Value extends string 8 | ? [isEmptyValue?: (value: Value) => boolean] 9 | : [isEmptyValue: (value: Value) => boolean] 10 | ): Validator => 11 | (value) => { 12 | const [isEmptyValue = isEmptyString] = args; 13 | 14 | if (!isEmptyValue(value)) { 15 | return validator(value); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Future, Option } from "@swan-io/boxed"; 2 | import { MutableRefObject, ReactElement } from "react"; 3 | 4 | export type OptionRecord = { 5 | [K in keyof T]-?: Option; 6 | }; 7 | 8 | export type ValidatorResult = ErrorMessage | void; 9 | 10 | export type Validator = ( 11 | value: Value, 12 | ) => ValidatorResult; 13 | 14 | export type Validity = 15 | | { readonly tag: "unknown" } 16 | | { readonly tag: "valid" } 17 | | { readonly tag: "invalid"; error: ErrorMessage }; 18 | 19 | export type FormStatus = "untouched" | "editing" | "submitting" | "submitted"; 20 | 21 | // Kudos to https://github.com/MinimaHQ/re-formality/blob/master/docs/02-ValidationStrategies.md 22 | export type Strategy = "onChange" | "onSuccess" | "onBlur" | "onSuccessOrBlur" | "onSubmit"; 23 | 24 | export type FieldState = { 25 | value: Value; 26 | valid: boolean; 27 | error: ErrorMessage | undefined; 28 | }; 29 | 30 | export type FormConfig, ErrorMessage = string> = { 31 | [N in keyof Values]: { 32 | initialValue: Values[N]; 33 | strategy?: Strategy; 34 | isEqual?: (value1: Values[N], value2: Values[N]) => boolean; 35 | sanitize?: (value: Values[N]) => Values[N]; 36 | validate?: ( 37 | value: Values[N], 38 | helpers: { 39 | focusField: (name: keyof Values) => void; 40 | getFieldValue: ( 41 | name: N, 42 | options?: { sanitize?: boolean }, 43 | ) => Values[N]; 44 | }, 45 | ) => ValidatorResult; 46 | }; 47 | }; 48 | 49 | export type Form, ErrorMessage = string> = { 50 | formStatus: FormStatus; 51 | 52 | Field: ((props: { 53 | name: N; 54 | children: ( 55 | props: FieldState & { 56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 | ref: MutableRefObject; 58 | onChange: (value: Values[N]) => void; 59 | onBlur: () => void; 60 | }, 61 | ) => ReactElement | null; 62 | }) => ReactElement | null) & { 63 | displayName?: string; 64 | }; 65 | 66 | FieldsListener: ((props: { 67 | names: N[]; 68 | children: (states: { 69 | [N1 in N]: FieldState; 70 | }) => ReactElement | null; 71 | }) => ReactElement | null) & { 72 | displayName?: string; 73 | }; 74 | 75 | getFieldValue: (name: N, options?: { sanitize?: boolean }) => Values[N]; 76 | getFieldRef: (name: keyof Values) => MutableRefObject; 77 | setFieldValue: ( 78 | name: N, 79 | value: Values[N], 80 | options?: { validate?: boolean }, 81 | ) => void; 82 | setFieldError: (name: keyof Values, error?: ErrorMessage) => void; 83 | 84 | focusField: (name: keyof Values) => void; 85 | resetField: (name: keyof Values) => void; 86 | sanitizeField: (name: keyof Values) => void; 87 | validateField: (name: keyof Values) => ValidatorResult; 88 | 89 | listenFields: ( 90 | names: N[], 91 | listener: (states: { 92 | [N1 in N]: FieldState; 93 | }) => void, 94 | ) => () => void; 95 | 96 | resetForm: () => void; 97 | submitForm: (options?: { 98 | onSuccess?: (values: OptionRecord) => Future | Promise | void; 99 | onFailure?: (errors: Partial>) => void; 100 | focusOnFirstError?: boolean; 101 | }) => void; 102 | }; 103 | -------------------------------------------------------------------------------- /src/useForm.ts: -------------------------------------------------------------------------------- 1 | import { Dict, Future, Option } from "@swan-io/boxed"; 2 | import { 3 | MutableRefObject, 4 | SetStateAction, 5 | useEffect, 6 | useMemo, 7 | useReducer, 8 | useRef, 9 | useSyncExternalStore, 10 | } from "react"; 11 | import { identity, isPromise, noop } from "./helpers"; 12 | import { 13 | FieldState, 14 | Form, 15 | FormConfig, 16 | FormStatus, 17 | OptionRecord, 18 | Strategy, 19 | ValidatorResult, 20 | Validity, 21 | } from "./types"; 22 | 23 | export const useForm = , ErrorMessage = string>( 24 | config: FormConfig, 25 | ): Form => { 26 | type Contract = Form; 27 | type Name = keyof Values; 28 | 29 | const [, forceUpdate] = useReducer(() => [], []); 30 | const mounted = useRef(false); 31 | const formStatus = useRef("untouched"); 32 | 33 | const arg = useRef(config); 34 | arg.current = config; 35 | 36 | useEffect(() => { 37 | mounted.current = true; 38 | 39 | return () => { 40 | mounted.current = false; 41 | }; 42 | }, []); 43 | 44 | const fields = useRef() as MutableRefObject<{ 45 | [N in Name]: { 46 | readonly callbacks: Set<() => void>; 47 | readonly ref: MutableRefObject; // eslint-disable-line @typescript-eslint/no-explicit-any 48 | mounted: boolean; 49 | state: Readonly<{ 50 | talkative: boolean; 51 | value: Values[N]; 52 | validity: Validity; 53 | }>; 54 | }; 55 | }>; 56 | 57 | const field = useRef() as MutableRefObject; 58 | const fieldsListener = useRef() as MutableRefObject; 59 | 60 | const api = useMemo(() => { 61 | const getIsEqual = (name: Name) => arg.current[name].isEqual ?? Object.is; 62 | const getInitialValue = (name: N) => arg.current[name].initialValue; 63 | const getSanitize = (name: N) => arg.current[name].sanitize ?? identity; 64 | const getStrategy = (name: Name) => arg.current[name].strategy ?? "onSuccessOrBlur"; 65 | const getValidate = (name: Name) => arg.current[name].validate ?? noop; 66 | 67 | const isMounted = (name: Name) => fields.current[name].mounted; 68 | const isTalkative = (name: Name) => fields.current[name].state.talkative; 69 | 70 | const setState = ( 71 | name: N, 72 | state: SetStateAction<{ 73 | value: Values[N]; 74 | talkative: boolean; 75 | validity: Validity; 76 | }>, 77 | ) => { 78 | fields.current[name].state = 79 | typeof state === "function" ? state(fields.current[name].state) : state; 80 | }; 81 | 82 | const getFieldState = (name: N): FieldState => { 83 | const { talkative, value, validity } = fields.current[name].state; 84 | 85 | // Avoid giving feedback too soon 86 | if (!talkative || validity.tag === "unknown") { 87 | return { value, valid: false, error: undefined }; 88 | } 89 | 90 | const sanitize = getSanitize(name); 91 | const isEqual = getIsEqual(name); 92 | 93 | return { 94 | value, 95 | valid: 96 | validity.tag === "valid" && !isEqual(sanitize(getInitialValue(name)), sanitize(value)), 97 | error: validity.tag === "invalid" ? validity.error : undefined, 98 | }; 99 | }; 100 | 101 | const runRenderCallbacks = (name: Name): void => { 102 | fields.current[name].callbacks.forEach((callback) => callback()); 103 | }; 104 | 105 | const setTalkative = (name: Name, strategies?: Strategy[]): void => { 106 | const strategy = getStrategy(name); 107 | 108 | if (!strategies || strategies.some((item) => strategy === item)) { 109 | setState(name, (prevState) => ({ 110 | ...prevState, 111 | talkative: true, 112 | })); 113 | } 114 | }; 115 | 116 | const getValidity = (error: ErrorMessage | void): Validity => 117 | typeof error === "undefined" ? { tag: "valid" } : { tag: "invalid", error }; 118 | 119 | const getFieldValue: Contract["getFieldValue"] = (name, options = {}) => { 120 | const { sanitize = false } = options; 121 | 122 | const value = 123 | fields.current[name] == null 124 | ? getInitialValue(name) // Could be null during lazy initialization 125 | : fields.current[name].state.value; 126 | 127 | return sanitize ? getSanitize(name)(value) : value; 128 | }; 129 | 130 | const getFieldRef = (name: Name) => { 131 | const { ref } = fields.current[name]; 132 | return ref as MutableRefObject; 133 | }; 134 | 135 | const focusField: Contract["focusField"] = (name) => { 136 | const { ref } = fields.current[name]; 137 | 138 | if (ref.current && typeof ref.current.focus === "function") { 139 | ref.current.focus(); 140 | } 141 | }; 142 | 143 | const internalValidateField = (name: N): ValidatorResult => { 144 | const validate = getValidate(name); 145 | 146 | const value = getFieldValue(name, { sanitize: true }); 147 | const error = validate(value, { getFieldValue, focusField }); 148 | 149 | if (error === undefined) { 150 | setTalkative(name, ["onSuccess", "onSuccessOrBlur"]); 151 | } 152 | 153 | setState(name, (prevState) => ({ 154 | ...prevState, 155 | validity: getValidity(error), 156 | })); 157 | 158 | runRenderCallbacks(name); 159 | return error; 160 | }; 161 | 162 | const setFieldValue: Contract["setFieldValue"] = (name, value, options = {}) => { 163 | const { validate = false } = options; 164 | 165 | setState(name, (prevState) => ({ 166 | ...prevState, 167 | value, 168 | })); 169 | 170 | if (validate) { 171 | setTalkative(name); 172 | } 173 | 174 | void internalValidateField(name); 175 | }; 176 | 177 | const setFieldError: Contract["setFieldError"] = (name, error) => { 178 | setState(name, (prevState) => ({ 179 | ...prevState, 180 | validity: getValidity(error), 181 | })); 182 | 183 | setTalkative(name); 184 | runRenderCallbacks(name); 185 | }; 186 | 187 | const resetField: Contract["resetField"] = (name) => { 188 | setState(name, { 189 | value: getInitialValue(name), 190 | talkative: false, 191 | validity: { tag: "unknown" }, 192 | }); 193 | 194 | runRenderCallbacks(name); 195 | }; 196 | 197 | const sanitizeField: Contract["sanitizeField"] = (name) => { 198 | const sanitize = getSanitize(name); 199 | 200 | setState(name, ({ talkative, value, validity }) => ({ 201 | value: sanitize(value), 202 | talkative, 203 | validity, 204 | })); 205 | 206 | runRenderCallbacks(name); 207 | }; 208 | 209 | const validateField: Contract["validateField"] = (name) => { 210 | if (!isMounted(name)) { 211 | return undefined; 212 | } 213 | 214 | setTalkative(name); 215 | return internalValidateField(name); 216 | }; 217 | 218 | const listenFields: Contract["listenFields"] = (names, listener) => { 219 | const callback = () => { 220 | listener( 221 | names.reduce( 222 | (acc, name) => { 223 | acc[name] = getFieldState(name); 224 | return acc; 225 | }, 226 | {} as { 227 | [N1 in (typeof names)[number]]: FieldState; 228 | }, 229 | ), 230 | ); 231 | }; 232 | 233 | names.forEach((name) => fields.current[name].callbacks.add(callback)); 234 | 235 | return () => { 236 | names.forEach((name) => { 237 | if (fields.current[name] != null) { 238 | fields.current[name].callbacks.delete(callback); 239 | } 240 | }); 241 | }; 242 | }; 243 | 244 | const getOnChange = 245 | (name: N) => 246 | (value: Values[N]): void => { 247 | setState(name, (prevState) => ({ 248 | ...prevState, 249 | value, 250 | })); 251 | 252 | setTalkative(name, ["onChange"]); 253 | 254 | if (formStatus.current === "untouched" || formStatus.current === "submitted") { 255 | formStatus.current = "editing"; 256 | forceUpdate(); 257 | } 258 | 259 | void internalValidateField(name); 260 | }; 261 | 262 | const getOnBlur = (name: Name) => (): void => { 263 | const { validity } = fields.current[name].state; 264 | 265 | // Avoid validating an untouched / already valid field 266 | if (validity.tag !== "unknown" && !isTalkative(name)) { 267 | setTalkative(name, ["onBlur", "onSuccessOrBlur"]); 268 | void internalValidateField(name); 269 | } 270 | }; 271 | 272 | const resetForm: Contract["resetForm"] = () => { 273 | Dict.keys(arg.current).forEach((name) => resetField(name)); 274 | formStatus.current = "untouched"; 275 | 276 | forceUpdate(); 277 | }; 278 | 279 | const focusFirstError = (names: Name[], results: ValidatorResult[]) => { 280 | const index = results.findIndex((result) => typeof result !== "undefined"); 281 | const name = names[index]; 282 | 283 | if (typeof name !== "undefined") { 284 | focusField(name); 285 | } 286 | }; 287 | 288 | const isSuccessfulSubmission = ( 289 | results: ValidatorResult[], 290 | ): results is undefined[] => results.every((result) => typeof result === "undefined"); 291 | 292 | const setFormSubmitted = () => { 293 | formStatus.current = "submitted"; 294 | 295 | if (mounted.current) { 296 | forceUpdate(); 297 | } 298 | }; 299 | 300 | const submitForm: Contract["submitForm"] = ({ 301 | onSuccess = noop, 302 | onFailure = noop, 303 | focusOnFirstError = true, 304 | } = {}) => { 305 | if (formStatus.current === "submitting") { 306 | return; // Avoid concurrent submissions 307 | } 308 | 309 | formStatus.current = "submitting"; 310 | 311 | const keys: Name[] = Dict.keys(fields.current); 312 | const names = keys.filter((name) => fields.current[name].mounted); 313 | const values = {} as OptionRecord; 314 | const errors: Partial> = {}; 315 | const results: ValidatorResult[] = []; 316 | 317 | keys.forEach((name) => { 318 | values[name] = Option.None(); 319 | }); 320 | 321 | names.forEach((name: Name, index) => { 322 | setTalkative(name); 323 | values[name] = Option.Some(getFieldValue(name, { sanitize: true })); 324 | results[index] = internalValidateField(name); 325 | }); 326 | 327 | if (isSuccessfulSubmission(results)) { 328 | const effect = onSuccess(values); 329 | 330 | // convert Future to Promise if needed 331 | const promiseEffect: Promise | void = Future.isFuture(effect) 332 | ? effect.toPromise() 333 | : effect; 334 | 335 | if (isPromise(promiseEffect)) { 336 | forceUpdate(); 337 | void promiseEffect.finally(setFormSubmitted); 338 | } else { 339 | setFormSubmitted(); 340 | } 341 | } else { 342 | if (focusOnFirstError) { 343 | focusFirstError(names, results); 344 | } 345 | 346 | names.forEach((name, index) => { 347 | errors[name] = results[index] as ErrorMessage | undefined; 348 | }); 349 | 350 | onFailure(errors); 351 | 352 | formStatus.current = "submitted"; 353 | forceUpdate(); 354 | } 355 | }; 356 | 357 | return { 358 | getFieldValue, 359 | getFieldRef, 360 | setFieldValue, 361 | setFieldError, 362 | focusField, 363 | resetField, 364 | sanitizeField, 365 | validateField, 366 | listenFields, 367 | 368 | resetForm, 369 | submitForm, 370 | 371 | getOnChange, 372 | getOnBlur, 373 | getFieldState, 374 | }; 375 | }, []); 376 | 377 | const tmp = {} as (typeof fields)["current"]; 378 | 379 | for (const name in arg.current) { 380 | if (Object.prototype.hasOwnProperty.call(arg.current, name)) { 381 | tmp[name] = fields.current?.[name] ?? { 382 | callbacks: new Set(), 383 | ref: { current: null }, 384 | mounted: false, 385 | state: { 386 | value: arg.current[name].initialValue, 387 | talkative: false, 388 | validity: { tag: "unknown" }, 389 | }, 390 | }; 391 | } 392 | } 393 | 394 | fields.current = tmp; 395 | 396 | // Lazy initialization 397 | if (!field.current) { 398 | const Field: Contract["Field"] = ({ name, children }) => { 399 | const { subscribe, getSnapshot } = useMemo( 400 | () => ({ 401 | getSnapshot: () => fields.current[name].state, 402 | subscribe: (callback: () => void): (() => void) => { 403 | fields.current[name].callbacks.add(callback); 404 | 405 | return () => { 406 | if (fields.current[name] != null) { 407 | fields.current[name].callbacks.delete(callback); 408 | } 409 | }; 410 | }, 411 | }), 412 | [name], 413 | ); 414 | 415 | useSyncExternalStore(subscribe, getSnapshot, getSnapshot); 416 | 417 | useEffect(() => { 418 | const isFirstMounting = !fields.current[name].mounted; 419 | 420 | if (isFirstMounting) { 421 | fields.current[name].mounted = true; 422 | } else { 423 | if (process.env.NODE_ENV === "development") { 424 | console.error( 425 | "Mounting multiple fields with identical names is not supported and will lead to errors", 426 | ); 427 | } 428 | } 429 | 430 | return () => { 431 | if (isFirstMounting) { 432 | if (fields.current[name] != null) { 433 | fields.current[name].mounted = false; 434 | } 435 | } 436 | }; 437 | }, [name]); 438 | 439 | return children({ 440 | ...api.getFieldState(name), 441 | ref: fields.current[name].ref, 442 | onBlur: useMemo(() => api.getOnBlur(name), [name]), 443 | onChange: useMemo(() => api.getOnChange(name), [name]), 444 | }); 445 | }; 446 | 447 | Field.displayName = "Field"; 448 | field.current = Field; 449 | 450 | const FieldsListener: Contract["FieldsListener"] = ({ names, children }) => { 451 | const { subscribe, getSnapshot } = useMemo( 452 | () => ({ 453 | getSnapshot: () => JSON.stringify(names.map((name) => fields.current[name].state)), 454 | subscribe: (callback: () => void): (() => void) => { 455 | names.forEach((name) => fields.current[name].callbacks.add(callback)); 456 | 457 | return () => { 458 | names.forEach((name) => fields.current[name].callbacks.delete(callback)); 459 | }; 460 | }, 461 | }), 462 | // eslint-disable-next-line react-hooks/exhaustive-deps 463 | [JSON.stringify(names)], 464 | ); 465 | 466 | useSyncExternalStore(subscribe, getSnapshot, getSnapshot); 467 | 468 | return children( 469 | names.reduce( 470 | (acc, name) => { 471 | acc[name] = api.getFieldState(name); 472 | return acc; 473 | }, 474 | {} as { 475 | [N1 in (typeof names)[number]]: FieldState; 476 | }, 477 | ), 478 | ); 479 | }; 480 | 481 | FieldsListener.displayName = "FieldsListener"; 482 | fieldsListener.current = FieldsListener; 483 | } 484 | 485 | return { 486 | formStatus: formStatus.current, 487 | 488 | Field: field.current, 489 | FieldsListener: fieldsListener.current, 490 | 491 | getFieldValue: api.getFieldValue, 492 | getFieldRef: api.getFieldRef, 493 | setFieldValue: api.setFieldValue, 494 | setFieldError: api.setFieldError, 495 | focusField: api.focusField, 496 | resetField: api.resetField, 497 | sanitizeField: api.sanitizeField, 498 | validateField: api.validateField, 499 | listenFields: api.listenFields, 500 | 501 | resetForm: api.resetForm, 502 | submitForm: api.submitForm, 503 | }; 504 | }; 505 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["__tests__"], 5 | "compilerOptions": { "noEmit": false } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "__tests__"], 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "target": "ES2019", 6 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 7 | "jsx": "react", 8 | "moduleResolution": "Node", 9 | "outDir": "dist/", 10 | 11 | "allowJs": false, 12 | "declaration": true, 13 | "noEmit": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "stripInternal": true, 18 | 19 | // https://www.typescriptlang.org/tsconfig#Type_Checking_6248 20 | "allowUnreachableCode": false, 21 | "allowUnusedLabels": false, 22 | "noFallthroughCasesInSwitch": true, 23 | "noImplicitOverride": true, 24 | "noUncheckedIndexedAccess": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { index: "src/index.ts" }, 5 | format: ["cjs", "esm"], 6 | target: "es2019", 7 | tsconfig: "./tsconfig.build.json", 8 | clean: true, 9 | dts: false, 10 | sourcemap: true, 11 | treeshake: true, 12 | }); 13 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "jsdom", 6 | setupFiles: ["vitest-setup.ts"], 7 | exclude: ["__tests__/utils"], 8 | include: ["__tests__/**/*.{ts,tsx}"], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /vitest-setup.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from "@testing-library/react"; 2 | import { afterEach } from "vitest"; 3 | 4 | // https://testing-library.com/docs/react-testing-library/api/#cleanup 5 | afterEach(cleanup); 6 | 7 | // https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#configuring-your-testing-environment 8 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 9 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | *.local 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | node_modules 6 | -------------------------------------------------------------------------------- /website/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @swan-io/use-form website 7 | 8 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @swan-io/use-form website 7 | 8 | 21 | 22 | 37 | 38 | 39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-form-website", 3 | "private": true, 4 | "scripts": { 5 | "build": "tsc && vite build && cp 404.html dist", 6 | "dev": "vite", 7 | "serve": "yarn build && vite preview" 8 | }, 9 | "dependencies": { 10 | "@chakra-ui/icons": "^2.1.1", 11 | "@chakra-ui/react": "^2.8.2", 12 | "@chakra-ui/system": "^2.6.2", 13 | "@emotion/react": "^11.11.4", 14 | "@emotion/styled": "^11.11.5", 15 | "@swan-io/boxed": "^3.0.0", 16 | "@swan-io/chicane": "^2.0.0", 17 | "@swan-io/use-form": "link:../", 18 | "card-validator": "^9.1.0", 19 | "framer-motion": "^11.0.24", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "rifm": "^0.12.1", 23 | "ts-pattern": "^5.1.0", 24 | "validator": "^13.11.0" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^20.12.4", 28 | "@types/react": "^18.2.74", 29 | "@types/react-dom": "^18.2.24", 30 | "@types/validator": "^13.11.9", 31 | "@vitejs/plugin-react-swc": "^3.6.0", 32 | "typescript": "^5.4.3", 33 | "vite": "^5.2.8" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /website/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { useDisclosure } from "@chakra-ui/hooks"; 3 | import { HamburgerIcon } from "@chakra-ui/icons"; 4 | import { Flex, Text, VStack } from "@chakra-ui/layout"; 5 | import { useBreakpointValue } from "@chakra-ui/media-query"; 6 | import * as React from "react"; 7 | import { P, match } from "ts-pattern"; 8 | import { Link } from "./components/Link"; 9 | import { Page } from "./components/Page"; 10 | import { AsyncSubmissionForm } from "./forms/AsyncSubmissionForm"; 11 | import { BasicForm } from "./forms/BasicForm"; 12 | import { CheckboxesForm } from "./forms/CheckboxesForm"; 13 | import { CreditCardForm } from "./forms/CreditCardForm"; 14 | import { Dynamic } from "./forms/Dynamic"; 15 | import { FieldsListenerForm } from "./forms/FieldsListenerForm"; 16 | import { IBANForm } from "./forms/IBANForm"; 17 | import { InputMaskingForm } from "./forms/InputMaskingForm"; 18 | import { StrategiesForm } from "./forms/StrategiesForm"; 19 | import { Router, routes } from "./utils/router"; 20 | 21 | export const App = () => { 22 | const route = Router.useRoute(routes); 23 | const isDesktop = !useBreakpointValue({ base: true, md: false }); 24 | const { isOpen, onToggle, onClose } = useDisclosure(); 25 | 26 | const pathKey = route?.key.split("-")[0]; 27 | React.useEffect(onClose, [pathKey]); 28 | 29 | return ( 30 | 31 | 42 | 43 | {(isDesktop || isOpen) && ( 44 | 60 | 68 | Examples 69 | 70 | 71 | 72 | Basic 73 | Validation strategies 74 | Fields listener 75 | Async submission 76 | Checkboxes 77 | IBAN 78 | Credit card 79 | Input masking 80 | Dynamic fields 81 | 82 | 83 | )} 84 | 85 | {match(route) 86 | .with({ name: "Home" }, () => ) 87 | .with({ name: "Strategies" }, () => ) 88 | .with({ name: "FieldsListener" }, () => ) 89 | .with({ name: "AsyncSubmission" }, () => ) 90 | .with({ name: "Checkboxes" }, () => ) 91 | .with({ name: "IBAN" }, () => ) 92 | .with({ name: "CreditCard" }, () => ) 93 | .with({ name: "InputMasking" }, () => ) 94 | .with({ name: "Dynamic" }, () => ) 95 | .with(P.nullish, () => ) 96 | .exhaustive()} 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /website/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { FormLabel } from "@chakra-ui/form-control"; 2 | import { useId } from "@chakra-ui/hooks"; 3 | import { CheckIcon, WarningIcon } from "@chakra-ui/icons"; 4 | import { Input as ChakraInput, InputGroup, InputProps, InputRightElement } from "@chakra-ui/input"; 5 | import { Box, Flex, Spacer, Text } from "@chakra-ui/layout"; 6 | import { Strategy } from "@swan-io/use-form"; 7 | import * as React from "react"; 8 | 9 | type Props = { 10 | error?: string; 11 | label: string; 12 | onBlur: () => void; 13 | validation?: string; 14 | strategy: Strategy; 15 | placeholder?: string; 16 | onChange?: InputProps["onChange"]; 17 | onChangeText?: (text: string) => void; 18 | valid: boolean; 19 | value: string; 20 | }; 21 | 22 | export const Input = React.forwardRef( 23 | ( 24 | { 25 | error, 26 | label, 27 | onBlur, 28 | validation, 29 | strategy, 30 | placeholder, 31 | onChange, 32 | onChangeText, 33 | valid, 34 | value, 35 | }, 36 | forwardedRef, 37 | ) => { 38 | const id = useId(); 39 | 40 | return ( 41 | 42 | 43 | {label} 44 | 45 | 46 | 47 | 48 | {validation} 49 | 50 | 51 | 52 | 53 | 54 | {strategy} ✨ 55 | 56 | 57 | 58 | 59 | { 67 | onChange?.(e); 68 | onChangeText?.(e.target.value); 69 | }} 70 | /> 71 | 72 | {valid && ( 73 | 74 | 75 | 76 | )} 77 | 78 | {error != null && ( 79 | 80 | 81 | 82 | )} 83 | 84 | 85 | 86 | 87 | 88 | {error != null && ( 89 | 90 | {error} 91 | 92 | )} 93 | 94 | 95 | ); 96 | }, 97 | ); 98 | -------------------------------------------------------------------------------- /website/src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@chakra-ui/system"; 2 | import { useLinkProps } from "@swan-io/chicane"; 3 | import * as React from "react"; 4 | 5 | export const Link = ({ to, children }: { to: string; children: string }) => { 6 | const { colors } = useTheme(); 7 | const { active, onClick } = useLinkProps({ href: to }); 8 | 9 | return ( 10 | 29 | {children} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /website/src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Heading, Spacer, Text } from "@chakra-ui/layout"; 2 | import * as React from "react"; 3 | 4 | export const Page = ({ 5 | children, 6 | title, 7 | description, 8 | }: { 9 | children?: React.ReactNode; 10 | title: string; 11 | description?: React.ReactNode; 12 | }) => ( 13 | 23 |
24 | {title} 25 | 26 | {description ? ( 27 | <> 28 | 29 | {description} 30 | 31 | 32 | ) : ( 33 | 34 | )} 35 | 36 | {children} 37 |
38 |
39 | ); 40 | -------------------------------------------------------------------------------- /website/src/forms/AsyncSubmissionForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { HStack, Spacer } from "@chakra-ui/layout"; 3 | import { useToast } from "@chakra-ui/toast"; 4 | import { useForm } from "@swan-io/use-form"; 5 | import * as React from "react"; 6 | import validator from "validator"; 7 | import { Input } from "../components/Input"; 8 | import { Page } from "../components/Page"; 9 | import { resolveAfter } from "../utils/promises"; 10 | 11 | export const AsyncSubmissionForm = () => { 12 | const { Field, resetForm, submitForm, formStatus } = useForm({ 13 | emailAddress: { 14 | strategy: "onSuccessOrBlur", 15 | initialValue: "", 16 | sanitize: (value) => value.trim(), 17 | validate: (value) => { 18 | if (!validator.isEmail(value)) { 19 | return "A valid email is required"; 20 | } 21 | }, 22 | }, 23 | }); 24 | 25 | const toast = useToast(); 26 | 27 | const onSubmit = (event: React.FormEvent) => { 28 | event.preventDefault(); 29 | 30 | submitForm({ 31 | onSuccess: (values) => 32 | resolveAfter(2000).then(() => { 33 | console.log("values", values); 34 | 35 | toast({ 36 | title: "Submission succeeded", 37 | status: "success", 38 | duration: 5000, 39 | isClosable: true, 40 | }); 41 | }), 42 | onFailure: (errors) => { 43 | console.log("errors", errors); 44 | 45 | toast({ 46 | title: "Submission failed", 47 | status: "error", 48 | duration: 5000, 49 | isClosable: true, 50 | }); 51 | }, 52 | }); 53 | }; 54 | 55 | return ( 56 | 60 |
{ 63 | resetForm(); 64 | }} 65 | > 66 | 67 | {({ error, onBlur, onChange, ref, valid, value }) => ( 68 | 80 | )} 81 | 82 | 83 | 84 | 85 | 86 | 89 | 90 | 99 | 100 | 101 |
102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /website/src/forms/BasicForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { HStack, Spacer } from "@chakra-ui/layout"; 3 | import { useToast } from "@chakra-ui/toast"; 4 | import { useForm } from "@swan-io/use-form"; 5 | import * as React from "react"; 6 | import validator from "validator"; 7 | import { Input } from "../components/Input"; 8 | import { Page } from "../components/Page"; 9 | 10 | export const BasicForm = () => { 11 | const { Field, resetForm, submitForm } = useForm({ 12 | firstName: { 13 | strategy: "onBlur", 14 | initialValue: "", 15 | sanitize: (value) => value.trim(), 16 | validate: (value) => { 17 | if (value === "") { 18 | return "First name is required"; 19 | } 20 | }, 21 | }, 22 | lastName: { 23 | strategy: "onBlur", 24 | initialValue: "", 25 | sanitize: (value) => value.trim(), 26 | validate: (value) => { 27 | if (value === "") { 28 | return "Last name is required"; 29 | } 30 | }, 31 | }, 32 | emailAddress: { 33 | strategy: "onSuccessOrBlur", 34 | initialValue: "", 35 | sanitize: (value) => value.trim(), 36 | validate: (value) => { 37 | if (!validator.isEmail(value)) { 38 | return "A valid email is required"; 39 | } 40 | }, 41 | }, 42 | }); 43 | 44 | const toast = useToast(); 45 | 46 | const onSubmit = (event: React.FormEvent) => { 47 | event.preventDefault(); 48 | 49 | submitForm({ 50 | onSuccess: (values) => { 51 | console.log("values", values); 52 | 53 | toast({ 54 | title: "Submission succeeded", 55 | status: "success", 56 | duration: 5000, 57 | isClosable: true, 58 | }); 59 | }, 60 | onFailure: (errors) => { 61 | console.log("errors", errors); 62 | 63 | toast({ 64 | title: "Submission failed", 65 | status: "error", 66 | duration: 5000, 67 | isClosable: true, 68 | }); 69 | }, 70 | }); 71 | }; 72 | 73 | return ( 74 | 78 | A common form example which play with at least two different strategies. 79 |
80 | Note that all values are sanitized using trimming. 81 | 82 | } 83 | > 84 |
{ 87 | resetForm(); 88 | }} 89 | > 90 | 91 | {({ error, onBlur, onChange, ref, valid, value }) => ( 92 | 104 | )} 105 | 106 | 107 | 108 | {({ error, onBlur, onChange, ref, valid, value }) => ( 109 | 121 | )} 122 | 123 | 124 | 125 | {({ error, onBlur, onChange, ref, valid, value }) => ( 126 | 138 | )} 139 | 140 | 141 | 142 | 143 | 144 | 147 | 148 | 151 | 152 | 153 |
154 | ); 155 | }; 156 | -------------------------------------------------------------------------------- /website/src/forms/CheckboxesForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { Checkbox } from "@chakra-ui/checkbox"; 3 | import { Code, HStack, Spacer } from "@chakra-ui/layout"; 4 | import { useToast } from "@chakra-ui/toast"; 5 | import { useForm } from "@swan-io/use-form"; 6 | import * as React from "react"; 7 | import { Page } from "../components/Page"; 8 | 9 | export const CheckboxesForm = () => { 10 | const { Field, resetForm, submitForm } = useForm({ 11 | termsAndConditions: { 12 | strategy: "onChange", 13 | initialValue: false, 14 | validate: (value) => { 15 | if (!value) { 16 | return "You must accept terms and conditions"; 17 | } 18 | }, 19 | }, 20 | emailsFromPartners: { 21 | strategy: "onChange", 22 | initialValue: false, 23 | validate: (value) => { 24 | if (!value) { 25 | return "You must accept to receive email from partners"; 26 | } 27 | }, 28 | }, 29 | }); 30 | 31 | const toast = useToast(); 32 | 33 | const onSubmit = (event: React.FormEvent) => { 34 | event.preventDefault(); 35 | 36 | submitForm({ 37 | onSuccess: (values) => { 38 | console.log("values", values); 39 | 40 | toast({ 41 | title: "Submission succeeded", 42 | status: "success", 43 | duration: 5000, 44 | isClosable: true, 45 | }); 46 | }, 47 | onFailure: (errors) => { 48 | console.log("errors", errors); 49 | 50 | toast({ 51 | title: "Submission failed", 52 | status: "error", 53 | duration: 5000, 54 | isClosable: true, 55 | }); 56 | }, 57 | }); 58 | }; 59 | 60 | return ( 61 | 65 | Checkboxes that must be ticked are a great use-case for onChange validation 66 | strategy. 67 | 68 | } 69 | > 70 |
{ 73 | resetForm(); 74 | }} 75 | > 76 | 77 | {({ error, onChange, value }) => ( 78 | onChange(e.target.checked)} 83 | color="gray.600" 84 | > 85 | Accept terms and conditions 86 | 87 | )} 88 | 89 | 90 | 91 | 92 | 93 | {({ error, onChange, value }) => ( 94 | onChange(e.target.checked)} 99 | color="gray.600" 100 | > 101 | Receive emails from partners 102 | 103 | )} 104 | 105 | 106 | 107 | 108 | 109 | 112 | 113 | 116 | 117 | 118 |
119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /website/src/forms/CreditCardForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { ExternalLinkIcon } from "@chakra-ui/icons"; 3 | import { HStack, Spacer } from "@chakra-ui/layout"; 4 | import { Link } from "@chakra-ui/react"; 5 | import { useToast } from "@chakra-ui/toast"; 6 | import { useForm } from "@swan-io/use-form"; 7 | import cardValidator from "card-validator"; 8 | import * as React from "react"; 9 | import { Input } from "../components/Input"; 10 | import { Page } from "../components/Page"; 11 | 12 | export const CreditCardForm = () => { 13 | const { Field, resetForm, submitForm } = useForm({ 14 | cardNumber: { 15 | strategy: "onSuccessOrBlur", 16 | initialValue: "", 17 | sanitize: (value) => value.trim(), 18 | validate: (value) => { 19 | if (!cardValidator.number(value).isValid) { 20 | return "Card number is invalid"; 21 | } 22 | }, 23 | }, 24 | expirationDate: { 25 | strategy: "onSuccessOrBlur", 26 | initialValue: "", 27 | sanitize: (value) => value.trim(), 28 | validate: (value) => { 29 | if (!cardValidator.expirationDate(value).isValid) { 30 | return "Expiration date is invalid"; 31 | } 32 | }, 33 | }, 34 | cvc: { 35 | strategy: "onSuccessOrBlur", 36 | initialValue: "", 37 | sanitize: (value) => value.trim(), 38 | validate: (value) => { 39 | if (!cardValidator.cvv(value).isValid) { 40 | return "CVC should have 3 characters"; 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | const toast = useToast(); 47 | 48 | const onSubmit = (event: React.FormEvent) => { 49 | event.preventDefault(); 50 | 51 | submitForm({ 52 | onSuccess: (values) => { 53 | console.log("values", values); 54 | 55 | toast({ 56 | title: "Submission succeeded", 57 | status: "success", 58 | duration: 5000, 59 | isClosable: true, 60 | }); 61 | }, 62 | onFailure: (errors) => { 63 | console.log("errors", errors); 64 | 65 | toast({ 66 | title: "Submission failed", 67 | status: "error", 68 | duration: 5000, 69 | isClosable: true, 70 | }); 71 | }, 72 | }); 73 | }; 74 | 75 | return ( 76 | 80 | You can try it by yourself using random credit card numbers from{" "} 81 | 87 | creditcardvalidator.org 88 | 89 |
90 | Validation is performed using{" "} 91 | 97 | card-validator 98 | 99 | 100 | } 101 | > 102 |
{ 105 | resetForm(); 106 | }} 107 | > 108 | 109 | {({ error, onBlur, onChange, ref, valid, value }) => ( 110 | 122 | )} 123 | 124 | 125 | 126 | {({ error, onBlur, onChange, ref, valid, value }) => ( 127 | 139 | )} 140 | 141 | 142 | 143 | {({ error, onBlur, onChange, ref, valid, value }) => ( 144 | 156 | )} 157 | 158 | 159 | 160 | 161 | 162 | 165 | 166 | 169 | 170 | 171 |
172 | ); 173 | }; 174 | -------------------------------------------------------------------------------- /website/src/forms/Dynamic.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { HStack, Spacer } from "@chakra-ui/layout"; 3 | import { useToast } from "@chakra-ui/toast"; 4 | import { useForm } from "@swan-io/use-form"; 5 | import * as React from "react"; 6 | import { Input } from "../components/Input"; 7 | import { Page } from "../components/Page"; 8 | 9 | export const Dynamic = () => { 10 | const [fields, setFields] = React.useState<{ key: string; name: string }[]>([]); 11 | 12 | const config = React.useMemo( 13 | () => 14 | Object.fromEntries( 15 | fields.map((item) => [ 16 | item.key, 17 | { 18 | strategy: "onBlur" as const, 19 | initialValue: "", 20 | sanitize: (value: string) => value.trim(), 21 | validate: (value: string) => { 22 | if (value === "") { 23 | return "First name is required"; 24 | } 25 | }, 26 | }, 27 | ]), 28 | ), 29 | [fields], 30 | ); 31 | 32 | const { Field, resetForm, submitForm } = useForm(config); 33 | 34 | const toast = useToast(); 35 | 36 | const onSubmit = (event: React.FormEvent) => { 37 | event.preventDefault(); 38 | 39 | submitForm({ 40 | onSuccess: (values) => { 41 | console.log("values", values); 42 | 43 | toast({ 44 | title: "Submission succeeded", 45 | status: "success", 46 | duration: 5000, 47 | isClosable: true, 48 | }); 49 | }, 50 | onFailure: (errors) => { 51 | console.log("errors", errors); 52 | 53 | toast({ 54 | title: "Submission failed", 55 | status: "error", 56 | duration: 5000, 57 | isClosable: true, 58 | }); 59 | }, 60 | }); 61 | }; 62 | 63 | return ( 64 | 68 | A common form example which play with at least two different strategies. 69 |
70 | Note that all values are sanitized using trimming. 71 | 72 | } 73 | > 74 | 75 | 86 | 89 | 90 | 91 | 92 | 93 |
{ 96 | resetForm(); 97 | }} 98 | > 99 | {fields.map((field) => ( 100 | 101 | {({ error, onBlur, onChange, ref, valid, value }) => ( 102 | 113 | )} 114 | 115 | ))} 116 | 117 | 118 | 119 | 120 | 123 | 124 | 127 | 128 | 129 |
130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /website/src/forms/FieldsListenerForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { Code, HStack, Spacer } from "@chakra-ui/layout"; 3 | import { useToast } from "@chakra-ui/toast"; 4 | import { useForm } from "@swan-io/use-form"; 5 | import * as React from "react"; 6 | import validator from "validator"; 7 | import { Input } from "../components/Input"; 8 | import { Page } from "../components/Page"; 9 | 10 | export const FieldsListenerForm = () => { 11 | const { Field, FieldsListener, resetForm, submitForm } = useForm({ 12 | firstName: { 13 | strategy: "onBlur", 14 | initialValue: "", 15 | sanitize: (value) => value.trim(), 16 | validate: (value) => { 17 | if (value === "") { 18 | return "First name is required"; 19 | } 20 | }, 21 | }, 22 | lastName: { 23 | strategy: "onBlur", 24 | initialValue: "", 25 | sanitize: (value) => value.trim(), 26 | validate: (value) => { 27 | if (value === "") { 28 | return "Last name is required"; 29 | } 30 | }, 31 | }, 32 | emailAddress: { 33 | strategy: "onSuccessOrBlur", 34 | initialValue: "", 35 | sanitize: (value) => value.trim(), 36 | validate: (value) => { 37 | if (!validator.isEmail(value)) { 38 | return "A valid email is required"; 39 | } 40 | }, 41 | }, 42 | }); 43 | 44 | const toast = useToast(); 45 | 46 | const onSubmit = (event: React.FormEvent) => { 47 | event.preventDefault(); 48 | 49 | submitForm({ 50 | onSuccess: (values) => { 51 | console.log("values", values); 52 | 53 | toast({ 54 | title: "Submission succeeded", 55 | status: "success", 56 | duration: 5000, 57 | isClosable: true, 58 | }); 59 | }, 60 | onFailure: (errors) => { 61 | console.log("errors", errors); 62 | 63 | toast({ 64 | title: "Submission failed", 65 | status: "error", 66 | duration: 5000, 67 | isClosable: true, 68 | }); 69 | }, 70 | }); 71 | }; 72 | 73 | return ( 74 | 78 | Using listenFields and the {""} component, 79 | it's really easy to synchronise components states and perform side-effects several fields 80 | values changes. 81 | 82 | } 83 | > 84 | 85 | {(states) => ( 86 |
 96 |             {JSON.stringify(states, null, 2)}
 97 |           
98 | )} 99 |
100 | 101 | 102 | 103 |
{ 106 | resetForm(); 107 | }} 108 | > 109 | 110 | {({ error, onBlur, onChange, ref, valid, value }) => ( 111 | 123 | )} 124 | 125 | 126 | 127 | {({ error, onBlur, onChange, ref, valid, value }) => ( 128 | 140 | )} 141 | 142 | 143 | 144 | {({ error, onBlur, onChange, ref, valid, value }) => ( 145 | 157 | )} 158 | 159 | 160 | 161 | 162 | 163 | 166 | 167 | 170 | 171 | 172 |
173 | ); 174 | }; 175 | -------------------------------------------------------------------------------- /website/src/forms/IBANForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { ExternalLinkIcon } from "@chakra-ui/icons"; 3 | import { HStack, Spacer } from "@chakra-ui/layout"; 4 | import { Link } from "@chakra-ui/react"; 5 | import { useToast } from "@chakra-ui/toast"; 6 | import { useForm } from "@swan-io/use-form"; 7 | import * as React from "react"; 8 | import validator from "validator"; 9 | import { Input } from "../components/Input"; 10 | import { Page } from "../components/Page"; 11 | 12 | export const IBANForm = () => { 13 | const { Field, resetForm, submitForm } = useForm({ 14 | iban: { 15 | strategy: "onSuccessOrBlur", 16 | initialValue: "", 17 | sanitize: (value) => value.trim(), 18 | validate: (value) => { 19 | if (!validator.isIBAN(value)) { 20 | return "Value is not a valid IBAN"; 21 | } 22 | }, 23 | }, 24 | }); 25 | 26 | const toast = useToast(); 27 | 28 | const onSubmit = (event: React.FormEvent) => { 29 | event.preventDefault(); 30 | 31 | submitForm({ 32 | onSuccess: (values) => { 33 | console.log("values", values); 34 | 35 | toast({ 36 | title: "Submission succeeded", 37 | status: "success", 38 | duration: 5000, 39 | isClosable: true, 40 | }); 41 | }, 42 | onFailure: (errors) => { 43 | console.log("errors", errors); 44 | 45 | toast({ 46 | title: "Submission failed", 47 | status: "error", 48 | duration: 5000, 49 | isClosable: true, 50 | }); 51 | }, 52 | }); 53 | }; 54 | 55 | return ( 56 | 60 | You can try it by yourself using random IBAN from{" "} 61 | 62 | randomiban.com 63 | 64 |
65 | Validation is performed using{" "} 66 | 72 | validator 73 | 74 | 75 | } 76 | > 77 |
{ 80 | resetForm(); 81 | }} 82 | > 83 | 84 | {({ error, onBlur, onChange, ref, valid, value }) => ( 85 | 97 | )} 98 | 99 | 100 | 101 | 102 | 103 | 106 | 107 | 110 | 111 | 112 |
113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /website/src/forms/InputMaskingForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { ExternalLinkIcon } from "@chakra-ui/icons"; 3 | import { HStack, Spacer } from "@chakra-ui/layout"; 4 | import { Link } from "@chakra-ui/react"; 5 | import { useToast } from "@chakra-ui/toast"; 6 | import { useForm } from "@swan-io/use-form"; 7 | import cardValidator from "card-validator"; 8 | import * as React from "react"; 9 | import { Rifm } from "rifm"; 10 | import { Input } from "../components/Input"; 11 | import { Page } from "../components/Page"; 12 | 13 | const formatCardNumber = (string: string) => { 14 | const digits = (string.match(/\d+/g) || []).join(""); 15 | const chars = digits.split(""); 16 | 17 | const res = chars 18 | .reduce( 19 | (acc, char, index) => ([4, 8, 12, 16].includes(index) ? `${acc} ${char}` : `${acc}${char}`), 20 | "", 21 | ) 22 | .substr(0, 19); 23 | 24 | return string.endsWith(" ") && [4, 9, 14, 19].includes(res.length) ? `${res} ` : res; 25 | }; 26 | 27 | const appendSpace = (res: string) => ([4, 9, 14].includes(res.length) ? `${res} ` : res); 28 | 29 | export const InputMaskingForm = () => { 30 | const { Field, resetForm, submitForm } = useForm({ 31 | cardNumber: { 32 | strategy: "onSuccessOrBlur", 33 | initialValue: "", 34 | sanitize: (value) => value.trim(), 35 | validate: (value) => { 36 | if (!cardValidator.number(value).isValid) { 37 | return "Card number is invalid"; 38 | } 39 | }, 40 | }, 41 | }); 42 | 43 | const toast = useToast(); 44 | 45 | const onSubmit = (event: React.FormEvent) => { 46 | event.preventDefault(); 47 | 48 | submitForm({ 49 | onSuccess: (values) => { 50 | console.log("values", values); 51 | 52 | toast({ 53 | title: "Submission succeeded", 54 | status: "success", 55 | duration: 5000, 56 | isClosable: true, 57 | }); 58 | }, 59 | onFailure: (errors) => { 60 | console.log("errors", errors); 61 | 62 | toast({ 63 | title: "Submission failed", 64 | status: "error", 65 | duration: 5000, 66 | isClosable: true, 67 | }); 68 | }, 69 | }); 70 | }; 71 | 72 | return ( 73 | 77 | You can try it by yourself using random credit card numbers from{" "} 78 | 84 | creditcardvalidator.org 85 | 86 |
87 | Validation is performed using{" "} 88 | 94 | card-validator 95 | 96 | 97 | } 98 | > 99 |
{ 102 | resetForm(); 103 | }} 104 | > 105 | 106 | {({ error, onBlur, onChange, ref, valid, value }) => ( 107 | 115 | {({ value, onChange }) => ( 116 | 128 | )} 129 | 130 | )} 131 | 132 | 133 | 134 | 135 | 136 | 139 | 140 | 143 | 144 | 145 |
146 | ); 147 | }; 148 | -------------------------------------------------------------------------------- /website/src/forms/StrategiesForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { HStack, Spacer } from "@chakra-ui/layout"; 3 | import { useToast } from "@chakra-ui/toast"; 4 | import { useForm } from "@swan-io/use-form"; 5 | import * as React from "react"; 6 | import { Input } from "../components/Input"; 7 | import { Page } from "../components/Page"; 8 | 9 | const sanitize = (value: string) => value.trim(); 10 | const validate = (value: string) => { 11 | if (value.length < 3) { 12 | return "Must be at least 3 characters"; 13 | } 14 | }; 15 | 16 | export const StrategiesForm = () => { 17 | const { Field, resetForm, submitForm } = useForm({ 18 | onChange: { 19 | strategy: "onChange", 20 | initialValue: "", 21 | sanitize, 22 | validate, 23 | }, 24 | onSuccess: { 25 | strategy: "onSuccess", 26 | initialValue: "", 27 | sanitize, 28 | validate, 29 | }, 30 | onBlur: { 31 | strategy: "onBlur", 32 | initialValue: "", 33 | sanitize, 34 | validate, 35 | }, 36 | onSuccessOrBlur: { 37 | strategy: "onSuccessOrBlur", 38 | initialValue: "", 39 | sanitize, 40 | validate, 41 | }, 42 | onSubmit: { 43 | strategy: "onSubmit", 44 | initialValue: "", 45 | sanitize, 46 | validate, 47 | }, 48 | }); 49 | 50 | const toast = useToast(); 51 | 52 | const onSubmit = (event: React.FormEvent) => { 53 | event.preventDefault(); 54 | 55 | submitForm({ 56 | onSuccess: (values) => { 57 | console.log("values", values); 58 | 59 | toast({ 60 | title: "Submission succeeded", 61 | status: "success", 62 | duration: 5000, 63 | isClosable: true, 64 | }); 65 | }, 66 | onFailure: (errors) => { 67 | console.log("errors", errors); 68 | 69 | toast({ 70 | title: "Submission failed", 71 | status: "error", 72 | duration: 5000, 73 | isClosable: true, 74 | }); 75 | }, 76 | }); 77 | }; 78 | 79 | return ( 80 | 84 |
{ 87 | resetForm(); 88 | }} 89 | > 90 | 91 | {({ error, onBlur, onChange, ref, valid, value }) => ( 92 | 104 | )} 105 | 106 | 107 | 108 | {({ error, onBlur, onChange, ref, valid, value }) => ( 109 | 121 | )} 122 | 123 | 124 | 125 | {({ error, onBlur, onChange, ref, valid, value }) => ( 126 | 138 | )} 139 | 140 | 141 | 142 | {({ error, onBlur, onChange, ref, valid, value }) => ( 143 | 155 | )} 156 | 157 | 158 | 159 | {({ error, onBlur, onChange, ref, valid, value }) => ( 160 | 172 | )} 173 | 174 | 175 | 176 | 177 | 178 | 181 | 182 | 185 | 186 | 187 |
188 | ); 189 | }; 190 | -------------------------------------------------------------------------------- /website/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import { App } from "./App"; 5 | 6 | const container = document.getElementById("root"); 7 | 8 | if (container != null) { 9 | const root = createRoot(container); 10 | 11 | root.render( 12 | 13 | 14 | , 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /website/src/utils/promises.ts: -------------------------------------------------------------------------------- 1 | export const resolveAfter = (delay: number, value?: T): Promise => 2 | new Promise((resolve) => { 3 | setTimeout(() => resolve(value), delay); 4 | }); 5 | 6 | export const rejectAfter = (delay: number, value?: T): Promise => 7 | new Promise((_, reject) => { 8 | setTimeout(() => reject(value), delay); 9 | }); 10 | -------------------------------------------------------------------------------- /website/src/utils/router.ts: -------------------------------------------------------------------------------- 1 | import { Dict } from "@swan-io/boxed"; 2 | import { createRouter } from "@swan-io/chicane"; 3 | 4 | const routesObject = { 5 | Home: "/", 6 | Strategies: "/strategies", 7 | FieldsListener: "/fields-listener", 8 | AsyncSubmission: "/async-submission", 9 | Checkboxes: "/checkboxes", 10 | IBAN: "/iban", 11 | CreditCard: "/credit-card", 12 | InputMasking: "/input-masking", 13 | Dynamic: "/dynamic", 14 | } as const; 15 | 16 | export const routes = Dict.keys(routesObject); 17 | 18 | export const Router = createRouter(routesObject, { 19 | basePath: "/use-form", 20 | }); 21 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "types": ["vite/client", "node"], 8 | "jsx": "react", 9 | "moduleResolution": "Node", 10 | 11 | "allowJs": false, 12 | "strict": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "incremental": false, 18 | "esModuleInterop": true, 19 | 20 | "allowSyntheticDefaultImports": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "forceConsistentCasingInFileNames": true, 23 | 24 | "paths": { 25 | "@swan-io/use-form": ["../src"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /website/vite.config.mts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import path from "path"; 3 | import url from "url"; 4 | import { defineConfig } from "vite"; 5 | 6 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 7 | const source = path.join(__dirname, "..", "src"); 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | base: "/use-form/", 12 | build: { sourcemap: true }, 13 | plugins: [react()], 14 | resolve: { 15 | alias: { "@swan-io/use-form": source }, 16 | dedupe: ["@swan-io/boxed", "react", "react-dom"], 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------