├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .github_changelog_generator ├── .gitignore ├── .nvmrc ├── .prettierrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── .DS_Store ├── .nvmrc ├── .parcelrc ├── babel.config.js ├── package-lock.json ├── package.json ├── sitemap-ignore.json ├── src │ ├── App.tsx │ ├── assets │ │ ├── OpenGraph.png │ │ ├── icon.svg │ │ ├── maskable-icon.svg │ │ ├── nav-button.svg │ │ └── readme-logo.svg │ ├── components │ │ ├── Editor.tsx │ │ ├── HeadingLink.tsx │ │ ├── Navigation.tsx │ │ └── examples │ │ │ ├── dynamic-forms.tsx │ │ │ └── static-forms.tsx │ ├── global.d.ts │ ├── index.html │ ├── index.tsx │ ├── pages │ │ ├── About.mdx │ │ ├── api │ │ │ ├── FielderProvider.mdx │ │ │ ├── useField.mdx │ │ │ ├── useForm.mdx │ │ │ ├── useFormContext.mdx │ │ │ └── useSubmit.mdx │ │ ├── examples │ │ │ ├── dynamic-forms.mdx │ │ │ └── static-forms.mdx │ │ └── guides │ │ │ ├── getting-started.mdx │ │ │ ├── react-native.mdx │ │ │ ├── submission.mdx │ │ │ ├── type-safety.mdx │ │ │ └── validation.mdx │ ├── robots.txt │ ├── routes.ts │ ├── scale.ts │ └── service-worker.tsx ├── tsconfig.json └── webpack.config.mjs ├── examples ├── 1-basics │ ├── .npmrc │ ├── .nvmrc │ ├── cypress.json │ ├── cypress │ │ ├── integration │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── index.html │ ├── package.json │ ├── setup.mjs │ ├── src │ │ ├── components │ │ │ ├── Card.css │ │ │ ├── Card.tsx │ │ │ └── index.ts │ │ ├── form │ │ │ ├── Form.tsx │ │ │ ├── FormContent.tsx │ │ │ └── index.ts │ │ ├── index.tsx │ │ └── util.tsx │ ├── styles │ │ ├── button.css │ │ ├── card.css │ │ ├── form.css │ │ ├── label.css │ │ ├── site.css │ │ ├── step.css │ │ └── style.css │ └── tsconfig.json ├── 2-multi-step │ ├── .npmrc │ ├── .nvmrc │ ├── cypress.json │ ├── cypress │ │ ├── integration │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── index.html │ ├── package.json │ ├── setup.mjs │ ├── src │ │ ├── components │ │ │ ├── Card.css │ │ │ ├── Card.tsx │ │ │ ├── FormSection.tsx │ │ │ └── index.ts │ │ ├── index.tsx │ │ ├── register-form │ │ │ ├── Form.tsx │ │ │ ├── index.ts │ │ │ └── sections │ │ │ │ ├── Credentials.tsx │ │ │ │ ├── Terms.tsx │ │ │ │ └── index.ts │ │ └── util.tsx │ ├── styles │ │ ├── button.css │ │ ├── card.css │ │ ├── form.css │ │ ├── label.css │ │ ├── site.css │ │ ├── step.css │ │ └── style.css │ └── tsconfig.json ├── 3-branching │ ├── .npmrc │ ├── .nvmrc │ ├── cypress.json │ ├── cypress │ │ ├── integration │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── index.html │ ├── package.json │ ├── setup.mjs │ ├── src │ │ ├── index.html │ │ ├── index.tsx │ │ └── register-form │ │ │ ├── Form.tsx │ │ │ ├── index.tsx │ │ │ ├── sections │ │ │ ├── GettingStarted.tsx │ │ │ ├── Login.tsx │ │ │ ├── SignUp.tsx │ │ │ └── index.ts │ │ │ └── validation.ts │ ├── styles │ │ ├── button.css │ │ ├── card.css │ │ ├── form.css │ │ ├── label.css │ │ ├── site.css │ │ ├── step.css │ │ └── style.css │ └── tsconfig.json ├── 4-native │ ├── .expo-shared │ │ └── assets.json │ ├── .gitignore │ ├── App.js │ ├── app.json │ ├── assets │ │ ├── icon.png │ │ └── splash.png │ ├── babel.config.js │ └── package.json └── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── preact └── package.json ├── react-to-preact.js ├── rollup.config.js ├── src ├── __snapshots__ │ └── useForm.spec.tsx.snap ├── actions │ ├── blurField.ts │ ├── mountField.ts │ ├── setFieldState.ts │ ├── setFieldValidation.ts │ ├── setFieldValue.ts │ ├── unmountField.ts │ ├── util.ts │ ├── validateField.ts │ └── validateSubmission.ts ├── context.ts ├── index.ts ├── types.ts ├── useField.spec.tsx ├── useField.ts ├── useForm.spec.tsx ├── useForm.ts ├── useSubmit.ts ├── useSynchronousReducer.ts ├── util │ └── index.ts └── validation │ ├── applyValidationToState.ts │ └── batchValidationErrors.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:react/recommended", 6 | "prettier" 7 | ], 8 | "plugins": ["react-hooks"], 9 | "parserOptions": { 10 | "ecmaVersion": 2018, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/explicit-module-boundary-types": "off", 15 | "@typescript-eslint/no-use-before-define": "off", 16 | "@typescript-eslint/no-explicit-any": "off", 17 | "@typescript-eslint/explicit-function-return-type": "off", 18 | "@typescript-eslint/array-type": ["error", { "default": "array-simple" }], 19 | "react/prop-types": "off", 20 | "react-hooks/rules-of-hooks": "error", 21 | "react-hooks/exhaustive-deps": "warn" 22 | }, 23 | "settings": { 24 | "react": { 25 | "version": "detect" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | tags: 6 | - v* 7 | pull_request: 8 | 9 | name: CI 10 | 11 | jobs: 12 | install: 13 | runs-on: ubuntu-latest 14 | container: 'node:16' 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Cache node modules 18 | id: cache 19 | uses: actions/cache@v2 20 | with: 21 | path: ./node_modules 22 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 23 | - if: steps.cache.outputs.cache-hit != 'true' 24 | run: npm ci 25 | 26 | build: 27 | needs: install 28 | runs-on: ubuntu-latest 29 | container: 30 | image: 'node:16' 31 | options: --user 1001 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Restore node modules 35 | uses: actions/cache@v2 36 | with: 37 | path: ./node_modules 38 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 39 | - run: npm run build 40 | - name: Upload artifacts 41 | uses: actions/upload-artifact@v3 42 | with: 43 | name: build-output 44 | path: dist 45 | 46 | test: 47 | needs: install 48 | runs-on: ubuntu-latest 49 | container: 'node:16' 50 | steps: 51 | - uses: actions/checkout@v2 52 | - name: Restore node modules 53 | uses: actions/cache@v2 54 | with: 55 | path: ./node_modules 56 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 57 | - run: npm run test -- --coverage 58 | - uses: codecov/codecov-action@v3 59 | 60 | lint: 61 | needs: install 62 | runs-on: ubuntu-latest 63 | container: 'node:16' 64 | steps: 65 | - uses: actions/checkout@v2 66 | - name: Restore node modules 67 | uses: actions/cache@v2 68 | with: 69 | path: ./node_modules 70 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 71 | - run: npm run lint 72 | 73 | typecheck: 74 | needs: install 75 | runs-on: ubuntu-latest 76 | container: 'node:16' 77 | steps: 78 | - uses: actions/checkout@v2 79 | - name: Restore node modules 80 | uses: actions/cache@v2 81 | with: 82 | path: ./node_modules 83 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 84 | - run: $(npm bin)/tsc --noEmit 85 | 86 | check-formatting: 87 | name: check formatting 88 | needs: install 89 | runs-on: ubuntu-latest 90 | container: 'node:16' 91 | steps: 92 | - uses: actions/checkout@v2 93 | - name: Restore node modules 94 | uses: actions/cache@v2 95 | with: 96 | path: ./node_modules 97 | key: nodemodules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 98 | - run: $(npm bin)/prettier --check . 99 | 100 | e2e-example: 101 | needs: build 102 | strategy: 103 | matrix: 104 | example: ['1-basics', '2-multi-step', '3-branching'] 105 | name: e2e - example ${{ matrix.example }} 106 | runs-on: ubuntu-latest 107 | container: 'cypress/browsers:node16.5.0-chrome94-ff93' 108 | steps: 109 | - uses: actions/checkout@v2 110 | - name: Download artifacts 111 | uses: actions/download-artifact@v3 112 | with: 113 | name: build-output 114 | path: dist 115 | - run: npm pack 116 | - name: remove registry pkg 117 | run: npm remove fielder 118 | working-directory: examples/${{ matrix.example }} 119 | - name: install dependencies 120 | run: npm install 121 | working-directory: examples/${{ matrix.example }} 122 | - name: install prebuilt package 123 | run: npm install ../../fielder*.tgz 124 | working-directory: examples/${{ matrix.example }} 125 | - name: check types 126 | run: $(npm bin)/tsc --noEmit 127 | working-directory: examples/${{ matrix.example }} 128 | - name: run tests 129 | run: npm run serve & sleep 10 && npm run test 130 | working-directory: examples/${{ matrix.example }} 131 | 132 | publish: 133 | if: startsWith(github.ref, 'refs/tags/v') 134 | needs: 135 | - install 136 | - build 137 | - test 138 | - check-formatting 139 | - lint 140 | - typecheck 141 | - e2e-example 142 | runs-on: ubuntu-latest 143 | container: 'node:16' 144 | steps: 145 | - uses: actions/checkout@v2 146 | - name: Get tag 147 | id: tag 148 | uses: dawidd6/action-get-tag@v1 149 | - name: Download artifacts 150 | uses: actions/download-artifact@v3 151 | with: 152 | name: build-output 153 | path: dist 154 | - name: Version package.json 155 | run: npm --no-git-tag-version version ${{steps.tag.outputs.tag}} 156 | - name: Create .npmrc 157 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 158 | - name: Publish 159 | run: npm publish 160 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | user=andyrichardson 2 | project=fielder 3 | header-label=# Changelog 4 | enhancement-label=**Additions:** 5 | enhancement-labels=Feature 6 | breaking-labels=Breaking 7 | exclude-labels=Dependencies,Question,No Changelog -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .rts2* 3 | .*cache* 4 | dist 5 | .docz 6 | 7 | node_modules 8 | # Only use lockfile at project root (not example repos) 9 | /examples/**/package-lock.json 10 | /examples/**/cypress/videos 11 | /preact/src 12 | yarn.lock 13 | # docz requires use of yarn 14 | !/docs/yarn.lock 15 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v2.2.0](https://github.com/andyrichardson/fielder/tree/v2.2.0) (2022-09-12) 4 | 5 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v2.1.1...v2.2.0) 6 | 7 | **Additions:** 8 | 9 | - Add support for persisting state [\#12](https://github.com/andyrichardson/fielder/issues/12) 10 | 11 | **Merged pull requests:** 12 | 13 | - Update docs dependencies [\#336](https://github.com/andyrichardson/fielder/pull/336) ([andyrichardson](https://github.com/andyrichardson)) 14 | - Add "fromState" to docs [\#335](https://github.com/andyrichardson/fielder/pull/335) ([andyrichardson](https://github.com/andyrichardson)) 15 | - Fix formatting [\#334](https://github.com/andyrichardson/fielder/pull/334) ([andyrichardson](https://github.com/andyrichardson)) 16 | - Move to Github Actions [\#333](https://github.com/andyrichardson/fielder/pull/333) ([andyrichardson](https://github.com/andyrichardson)) 17 | - Fix e2e tests [\#321](https://github.com/andyrichardson/fielder/pull/321) ([andyrichardson](https://github.com/andyrichardson)) 18 | - Add value restoration \(optional\) [\#320](https://github.com/andyrichardson/fielder/pull/320) ([andyrichardson](https://github.com/andyrichardson)) 19 | 20 | ## [v2.1.1](https://github.com/andyrichardson/fielder/tree/v2.1.1) (2021-11-02) 21 | 22 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v2.1.0...v2.1.1) 23 | 24 | **Closed issues:** 25 | 26 | - useLayoutEffect warning when server side rendering [\#317](https://github.com/andyrichardson/fielder/issues/317) 27 | 28 | **Merged pull requests:** 29 | 30 | - Fix next.js support [\#318](https://github.com/andyrichardson/fielder/pull/318) ([andyrichardson](https://github.com/andyrichardson)) 31 | - Update dev deps [\#302](https://github.com/andyrichardson/fielder/pull/302) ([andyrichardson](https://github.com/andyrichardson)) 32 | 33 | ## [2.1.0](https://github.com/andyrichardson/fielder/tree/2.1.0) (2021-01-13) 34 | 35 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v2.0.0...2.1.0) 36 | 37 | **Additions:** 38 | 39 | - Add preact support [\#299](https://github.com/andyrichardson/fielder/issues/299) 40 | 41 | **Merged pull requests:** 42 | 43 | - Add preact support [\#300](https://github.com/andyrichardson/fielder/pull/300) ([andyrichardson](https://github.com/andyrichardson)) 44 | 45 | ## [v2.0.0](https://github.com/andyrichardson/fielder/tree/v2.0.0) 46 | 47 | ### A new major release! 48 | 49 | That's right, a new major release jam packed full of new features and with a side helping of breaking changes. 50 | 51 | ### New docs site 52 | 53 | Technically not part of this release but definitely worth a visit to check out the new guides! 54 | 55 | [Visit the docs!](https://fielder.andyrichardson.dev) 56 | 57 | ### Event driven validation 58 | 59 | Validation is now tailored to specific events (`mount`, `blur`, `submit`, etc). 60 | 61 | This provides extra flexibility to tweak validation for specific scenarios - such as triggering async validation only on submit (see example below). 62 | 63 | ```tsx 64 | useField({ 65 | validate: useCallback(({ trigger, value }) { 66 | // Validation for all events 67 | if (!value) { 68 | throw Error("Value is required."); 69 | } 70 | 71 | // Validation for blur only events 72 | if (trigger == "blur" && value.length < 4) { 73 | throw Error("Value must be at least 4 characters."); 74 | } 75 | 76 | // Async validation only on submit 77 | if (trigger == "submit") { 78 | return serverSideValidation(value).then((isValid) => { 79 | if (!isValid) { 80 | throw Error("Server side validation failed"); 81 | } 82 | }); 83 | } 84 | }, []) 85 | }) 86 | ``` 87 | 88 | More info on event driven validation can be [found here](https://fielder.andyrichardson.dev/guides/validation#validation-events). 89 | 90 | ### useSubmit hook 91 | 92 | There's a new `useSubmit` hook which: 93 | 94 | - triggers the new `submit` validation event 95 | - aggregates field values into a single object 96 | - tracks state of async submission validation 97 | - guards submission logic until validation succeeds 98 | 99 | ```tsx 100 | const { isValidating, hasSubmitted, handleSubmit } = useSubmit(() => { 101 | console.log('This is only called if submission validation succeeds!'); 102 | }); 103 | 104 | handleSubmit(); // Trigger submission 105 | ``` 106 | 107 | More info on submission can be [found here](https://fielder.andyrichardson.dev/guides/submission). 108 | 109 | ### Breaking changes 110 | 111 | - `initialValue` argument on the `useField` hook is now required [(more info)](https://fielder.andyrichardson.dev/api/useField#initialvalue-required) 112 | - `validate` argument on the `useField` hook now receives only a single argument [(more info)](https://fielder.andyrichardson.dev/guides/validation#basic-validation) 113 | - removed deprecated properties `touched` and `initalTouched` on the `useField` hook [(more info)](https://fielder.andyrichardson.dev/api/useField#arguments) 114 | - removed `initialValid` and `initialError` arguments on the `useField` hook in favor of validation events [(more info)](https://fielder.andyrichardson.dev/guides/validation#validation-events) 115 | - removed `validateOnBlur`, `validateOnChange`, and `validateOnUpdate` arguments on the `useField` hook in favor of validation events [(more info)](https://fielder.andyrichardson.dev/guides/validation#validation-events) 116 | - removed support for returning validation errors as strings without throwing [(more info)](https://fielder.andyrichardson.dev/api/useField#validate) 117 | 118 | ### Breaking changes 119 | 120 | #### 121 | 122 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.3.1...v2.0.0) 123 | 124 | ## [v1.3.1](https://github.com/andyrichardson/fielder/tree/v1.3.1) (2020-07-30) 125 | 126 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.3.0...v1.3.1) 127 | 128 | **Fixed bugs:** 129 | 130 | - Changes to validation arg doesn't update validation function [\#235](https://github.com/andyrichardson/fielder/issues/235) 131 | 132 | **Merged pull requests:** 133 | 134 | - Add setFieldState call to useField on validation change [\#278](https://github.com/andyrichardson/fielder/pull/278) ([andyrichardson](https://github.com/andyrichardson)) 135 | 136 | ## [v1.3.0](https://github.com/andyrichardson/fielder/tree/v1.3.0) (2020-06-14) 137 | 138 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.2.1...v1.3.0) 139 | 140 | **Additions:** 141 | 142 | - Add support for providing value to `onChange` [\#41](https://github.com/andyrichardson/fielder/issues/41) 143 | - Add support for React Native [\#35](https://github.com/andyrichardson/fielder/issues/35) 144 | - Add non DOM event support to onChange [\#123](https://github.com/andyrichardson/fielder/pull/123) ([andyrichardson](https://github.com/andyrichardson)) 145 | 146 | **Closed issues:** 147 | 148 | - Add sitemap to docs [\#49](https://github.com/andyrichardson/fielder/issues/49) 149 | - Update branching example to use consistent theming [\#47](https://github.com/andyrichardson/fielder/issues/47) 150 | 151 | **Merged pull requests:** 152 | 153 | - Add native example [\#124](https://github.com/andyrichardson/fielder/pull/124) ([andyrichardson](https://github.com/andyrichardson)) 154 | - Update readme with video [\#122](https://github.com/andyrichardson/fielder/pull/122) ([andyrichardson](https://github.com/andyrichardson)) 155 | - Add e2e for example 3 [\#84](https://github.com/andyrichardson/fielder/pull/84) ([andyrichardson](https://github.com/andyrichardson)) 156 | - Add sitemap generation on build [\#83](https://github.com/andyrichardson/fielder/pull/83) ([andyrichardson](https://github.com/andyrichardson)) 157 | - Keep styling consistent between examples [\#48](https://github.com/andyrichardson/fielder/pull/48) ([andyrichardson](https://github.com/andyrichardson)) 158 | 159 | ## [v1.2.1](https://github.com/andyrichardson/fielder/tree/v1.2.1) (2020-02-25) 160 | 161 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.2.0...v1.2.1) 162 | 163 | **Fixed bugs:** 164 | 165 | - Duplicate field - add warning instead of error [\#45](https://github.com/andyrichardson/fielder/pull/45) ([andyrichardson](https://github.com/andyrichardson)) 166 | 167 | **Closed issues:** 168 | 169 | - Add docs for `branching` [\#26](https://github.com/andyrichardson/fielder/issues/26) 170 | 171 | **Merged pull requests:** 172 | 173 | - Add example branching [\#46](https://github.com/andyrichardson/fielder/pull/46) ([andyrichardson](https://github.com/andyrichardson)) 174 | 175 | ## [v1.2.0](https://github.com/andyrichardson/fielder/tree/v1.2.0) (2020-02-05) 176 | 177 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.1.2...v1.2.0) 178 | 179 | **Breaking changes:** 180 | 181 | - Revert checked state managment using ref [\#39](https://github.com/andyrichardson/fielder/issues/39) 182 | - Remove checkbox refs [\#40](https://github.com/andyrichardson/fielder/pull/40) ([andyrichardson](https://github.com/andyrichardson)) 183 | 184 | **Additions:** 185 | 186 | - Add tracking of 'hasChanged' and 'hasBlurred' states [\#21](https://github.com/andyrichardson/fielder/issues/21) 187 | 188 | **Merged pull requests:** 189 | 190 | - Use 'latest' tag for dependencies examples [\#43](https://github.com/andyrichardson/fielder/pull/43) ([andyrichardson](https://github.com/andyrichardson)) 191 | - Update checkbox docs [\#42](https://github.com/andyrichardson/fielder/pull/42) ([andyrichardson](https://github.com/andyrichardson)) 192 | - Add cypress integration [\#38](https://github.com/andyrichardson/fielder/pull/38) ([andyrichardson](https://github.com/andyrichardson)) 193 | - Add hasChanged and hasBlurred props to useField [\#37](https://github.com/andyrichardson/fielder/pull/37) ([andyrichardson](https://github.com/andyrichardson)) 194 | - Update examples [\#36](https://github.com/andyrichardson/fielder/pull/36) ([andyrichardson](https://github.com/andyrichardson)) 195 | 196 | ## [v1.1.2](https://github.com/andyrichardson/fielder/tree/v1.1.2) (2020-01-21) 197 | 198 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.1.1...v1.1.2) 199 | 200 | **Fixed bugs:** 201 | 202 | - Fix initial value for radio inputs [\#30](https://github.com/andyrichardson/fielder/issues/30) 203 | - Fix initial value for checkbox inputs [\#28](https://github.com/andyrichardson/fielder/issues/28) 204 | - Textarea onChange error [\#24](https://github.com/andyrichardson/fielder/issues/24) 205 | 206 | **Closed issues:** 207 | 208 | - Add examples for validation and conditional rendering [\#33](https://github.com/andyrichardson/fielder/issues/33) 209 | - Add documentation for checkbox inputs [\#23](https://github.com/andyrichardson/fielder/issues/23) 210 | - Explain how to handle submission of field values [\#22](https://github.com/andyrichardson/fielder/issues/22) 211 | 212 | **Merged pull requests:** 213 | 214 | - Add conditional examples for validation [\#34](https://github.com/andyrichardson/fielder/pull/34) ([andyrichardson](https://github.com/andyrichardson)) 215 | - Add docs for radio / checkbox usage [\#32](https://github.com/andyrichardson/fielder/pull/32) ([andyrichardson](https://github.com/andyrichardson)) 216 | - Fix radio input [\#31](https://github.com/andyrichardson/fielder/pull/31) ([andyrichardson](https://github.com/andyrichardson)) 217 | - Add mutation of checkbox element on ref and value change [\#29](https://github.com/andyrichardson/fielder/pull/29) ([andyrichardson](https://github.com/andyrichardson)) 218 | - Add textarea support [\#27](https://github.com/andyrichardson/fielder/pull/27) ([andyrichardson](https://github.com/andyrichardson)) 219 | 220 | ## [v1.1.1](https://github.com/andyrichardson/fielder/tree/v1.1.1) (2020-01-10) 221 | 222 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.1.0...v1.1.1) 223 | 224 | **Fixed bugs:** 225 | 226 | - Handle destroy on unmount changing value [\#18](https://github.com/andyrichardson/fielder/issues/18) 227 | 228 | **Merged pull requests:** 229 | 230 | - Update deps [\#20](https://github.com/andyrichardson/fielder/pull/20) ([andyrichardson](https://github.com/andyrichardson)) 231 | - Add detection for destroyOnUnmount change [\#19](https://github.com/andyrichardson/fielder/pull/19) ([andyrichardson](https://github.com/andyrichardson)) 232 | 233 | ## [v1.1.0](https://github.com/andyrichardson/fielder/tree/v1.1.0) (2020-01-09) 234 | 235 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.0.3...v1.1.0) 236 | 237 | **Breaking changes:** 238 | 239 | - Fields without validation should be valid by default [\#11](https://github.com/andyrichardson/fielder/issues/11) 240 | 241 | **Closed issues:** 242 | 243 | - Add docs [\#4](https://github.com/andyrichardson/fielder/issues/4) 244 | 245 | **Merged pull requests:** 246 | 247 | - Setup linting [\#16](https://github.com/andyrichardson/fielder/pull/16) ([andyrichardson](https://github.com/andyrichardson)) 248 | - Default to valid when no validation is set [\#15](https://github.com/andyrichardson/fielder/pull/15) ([andyrichardson](https://github.com/andyrichardson)) 249 | - Add docs [\#5](https://github.com/andyrichardson/fielder/pull/5) ([andyrichardson](https://github.com/andyrichardson)) 250 | 251 | ## [v1.0.3](https://github.com/andyrichardson/fielder/tree/v1.0.3) (2019-12-22) 252 | 253 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/v1.0.2...v1.0.3) 254 | 255 | **Fixed bugs:** 256 | 257 | - Error thrown when using checkboxes [\#6](https://github.com/andyrichardson/fielder/issues/6) 258 | - No `dist` in published version 1.0.0 [\#1](https://github.com/andyrichardson/fielder/issues/1) 259 | 260 | **Closed issues:** 261 | 262 | - Remove unnecessary dependency "react-dom" [\#8](https://github.com/andyrichardson/fielder/issues/8) 263 | 264 | **Merged pull requests:** 265 | 266 | - Changelog guide + version bump [\#10](https://github.com/andyrichardson/fielder/pull/10) ([andyrichardson](https://github.com/andyrichardson)) 267 | - Remove react-dom [\#9](https://github.com/andyrichardson/fielder/pull/9) ([andyrichardson](https://github.com/andyrichardson)) 268 | - Fix checkbox issue [\#7](https://github.com/andyrichardson/fielder/pull/7) ([andyrichardson](https://github.com/andyrichardson)) 269 | - Remove `;` [\#2](https://github.com/andyrichardson/fielder/pull/2) ([jontansey](https://github.com/jontansey)) 270 | 271 | ## [v1.0.2](https://github.com/andyrichardson/fielder/tree/v1.0.2) (2019-11-27) 272 | 273 | [Full Changelog](https://github.com/andyrichardson/fielder/compare/bc3999d02980d5028bd094ca0afc59f9d72f1340...v1.0.2) 274 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How do I publish a new version? 2 | 3 | ### 1. Update the version 4 | 5 | Set the version attribute in the _package.json_ 6 | 7 | ### 2. Build the new changelog 8 | 9 | > Note: This step requires docker 10 | 11 | ``` 12 | npm run changelog -- --future-release [release version] --token [your github oauth token] 13 | ``` 14 | 15 | ### 3. Push/merge new version to master 16 | 17 | ``` 18 | git add CHANGELOG.md 19 | git commit -m "Version v0.0.0" 20 | git push origin master 21 | ``` 22 | 23 | ### 4. Publish new release 24 | 25 | **Warning:** This will publish a new release to the npmjs registry. 26 | 27 | _(replace v0.0.0 with your new version)_ 28 | 29 | ``` 30 | git fetch origin master 31 | git tag v0.0.0 origin/master 32 | git push origin v0.0.0 33 | ``` 34 | 35 | ### 5. Create a new release on Github 36 | 37 | Finally, navigate to [releases](https://github.com/andyrichardson/fielder/releases) and choose _draft a new release_. 38 | 39 | > Note: You can copy and paste release info from the changelog you just generated 40 | 41 | ### 6. Release updated docs 42 | 43 | ``` 44 | cd docs 45 | yarn 46 | yarn build 47 | yarn deploy 48 | ``` 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andy Richardson 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 |

2 | Fielder logo 3 |

4 | 5 |

Fielder

6 |

A field-first form library for React and React Native.

7 | 8 |

9 | 10 | build 11 | 12 | 13 | version 14 | 15 | 16 | size 17 | 18 | 19 | coverage 20 | 21 | 22 | docs 23 | 24 |

25 | 26 | # About 27 | 28 | _Fielder_ is a form library for React and React Native that has been built from the ground up with a [field-first approach to validation](https://dev.to/andyrichardsonn/why-we-need-another-form-library-fielder-4eah). 29 | 30 | ## Features 31 | 32 | ⚡️ **Synchronous validation** - _no cascading renders_ 33 | 34 | 🛎 **Validation events** - _validation can differ per event (change, blur, submit, etc.)_ 35 | 36 | 🪝 **Hooks that work** - _hooks respond to validation changes_ 37 | 38 | 🧠 **Evolving schemas** - _validation logic evolves with the UI_ 39 | 40 | ## Basic usage 41 | 42 | ### Install Fielder 43 | 44 | Add Fielder to your project. 45 | 46 | ```sh 47 | npm i fielder 48 | ``` 49 | 50 | ### Import the module 51 | 52 | Use `fielder` or `fielder/preact`. 53 | 54 | ```tsx 55 | // React 56 | import { useForm, ... } from 'fielder'; 57 | 58 | // Preact 59 | import { useForm, ... } from 'fielder/preact'; 60 | ``` 61 | 62 | ### Set up a form 63 | 64 | Use the `useForm` hook to create a form. 65 | 66 | ```tsx 67 | const myForm = useForm(); 68 | 69 | return {children}; 70 | ``` 71 | 72 | ### Create some fields 73 | 74 | Use the `useField` hook to create a field. 75 | 76 | ```tsx 77 | const [usernameProps, usernameMeta] = useField({ 78 | name: 'username', 79 | initialValue: '', 80 | validate: useCallback(({ value }) => { 81 | if (!value) { 82 | throw Error('Username is required!'); 83 | } 84 | }, []), 85 | }); 86 | 87 | return ( 88 | <> 89 | 90 | {usernameMeta.error} 91 | 92 | ); 93 | ``` 94 | 95 | ### Additional info 96 | 97 | Once you're all set up, be sure to check out [the guides](http://fielder.andyrichardson.dev/guides/getting-started) for a deeper dive! 98 | 99 | ## Additional resources 100 | 101 | For more info, tutorials and examples, visit the **[official docs site](https://fielder.andyrichardson.dev/)**! 102 | 103 | Also check out: 104 | 105 | - [[Article] Why we need another form library](https://dev.to/andyrichardsonn/why-we-need-another-form-library-fielder-4eah) 106 | - [[Benchmark] Fielder vs Formik](https://github.com/andyrichardson/fielder-benchmark) 107 | - [[Video] Getting started with Fielder](https://www.youtube.com/watch?v=wSorSlCkJwk) 108 | -------------------------------------------------------------------------------- /docs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andyrichardson/fielder/07e6ec1062657c1122fc29b36464dc31b41af3e8/docs/.DS_Store -------------------------------------------------------------------------------- /docs/.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /docs/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{jpg,png,svg}": ["@parcel/transformer-raw"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/env', 5 | { 6 | targets: ['last 1 version', 'not IE 11'], 7 | }, 8 | ], 9 | '@babel/typescript', 10 | '@babel/react', 11 | '@linaria', 12 | ], 13 | plugins: ['@babel/transform-runtime', '@babel/plugin-syntax-dynamic-import'], 14 | }; 15 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fielder-docs", 3 | "version": "1.0.0", 4 | "description": "Fielder Docs Site", 5 | "main": "dist/index.html", 6 | "scripts": { 7 | "start": "webpack serve", 8 | "build": "NODE_ENV=production webpack", 9 | "build:meta": "react-snap", 10 | "build:sitemap": "sitemap-static --prefix=https://fielder.andyrichardson.dev/ --pretty --ignore-file=sitemap-ignore.json dist > dist/sitemap.xml", 11 | "postbuild": "npm run build:meta && npm run build:sitemap", 12 | "deploy": "(echo \"fielder.andyrichardson.dev\" > dist/CNAME) && gh-pages -d dist" 13 | }, 14 | "author": "Andy Richardson", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@babel/core": "^7.19.0", 18 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 19 | "@babel/preset-env": "^7.19.0", 20 | "@babel/preset-react": "^7.18.6", 21 | "@babel/preset-typescript": "^7.18.6", 22 | "@linaria/webpack-loader": "^4.1.3", 23 | "@linaria/webpack5-loader": "^4.1.3", 24 | "@mdx-js/loader": "^2.1.3", 25 | "@mdx-js/mdx": "^2.1.3", 26 | "babel-loader": "^8.2.5", 27 | "clean-webpack-plugin": "^4.0.0", 28 | "copy-webpack-plugin": "^11.0.0", 29 | "css-loader": "^6.7.1", 30 | "favicons": "^6.0.0", 31 | "favicons-webpack-plugin": "^5.0.2", 32 | "gh-pages": "^4.0.0", 33 | "html-webpack-plugin": "^5.5.0", 34 | "mini-css-extract-plugin": "^2.6.1", 35 | "react-snap": "^1.23.0", 36 | "remark-prism": "^1.3.6", 37 | "remark-slug": "^7.0.1", 38 | "sitemap-static": "^0.4.4", 39 | "terser-webpack-plugin": "^5.3.6", 40 | "typescript": "^4.8.3", 41 | "webpack": "^5.74.0", 42 | "webpack-bundle-analyzer": "^4.6.1", 43 | "webpack-cli": "^4.10.0", 44 | "webpack-dev-server": "^4.11.0", 45 | "workbox-webpack-plugin": "^6.5.4" 46 | }, 47 | "dependencies": { 48 | "@fontsource/inter": "^4.5.12", 49 | "@fontsource/source-code-pro": "^4.5.12", 50 | "@linaria/babel-preset": "^4.2.0", 51 | "@linaria/core": "^4.1.2", 52 | "@linaria/react": "^4.1.3", 53 | "@linaria/shaker": "^4.2.0", 54 | "@mdx-js/react": "^2.1.3", 55 | "@philpl/buble": "^0.19.7", 56 | "fielder": "^2.1.1", 57 | "hoofd": "^1.5.2", 58 | "polished": "^4.2.2", 59 | "preact": "^10.11.0", 60 | "prism": "^4.1.2", 61 | "prism-themes": "^1.9.0", 62 | "react-helmet": "^6.1.0", 63 | "react-live": "^2.4.1", 64 | "workbox-core": "^6.5.4", 65 | "workbox-precaching": "^6.5.4", 66 | "workbox-routing": "^6.5.4", 67 | "workbox-strategies": "^6.5.4", 68 | "wouter": "^2.7.5", 69 | "wouter-preact": "^2.7.5" 70 | }, 71 | "reactSnap": { 72 | "source": "dist", 73 | "puppeteerArgs": [ 74 | "--no-sandbox", 75 | "--disable-setuid-sandbox" 76 | ], 77 | "minifyHtml": { 78 | "collapseWhitespace": false, 79 | "removeComments": false 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /docs/sitemap-ignore.json: -------------------------------------------------------------------------------- 1 | ["dist/404.html", "dist/200.html"] 2 | -------------------------------------------------------------------------------- /docs/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Suspense, useCallback, useEffect, useState } from 'react'; 2 | import { styled } from '@linaria/react'; 3 | import { useHead } from 'hoofd'; 4 | import { MDXProvider } from '@mdx-js/react'; 5 | import { Navigation } from './components/Navigation'; 6 | import * as headings from './components/HeadingLink'; 7 | import navButton from './assets/nav-button.svg'; 8 | import ogImage from './assets/OpenGraph.png'; 9 | import { Switch, Route, useLocation, Router, Redirect } from 'wouter'; 10 | import { LiteralRoute, literalRoutes } from './routes'; 11 | import { scale } from './scale'; 12 | 13 | export const App = () => { 14 | const [location] = useLocation(); 15 | const [collapsed, setCollapsed] = useState(true); 16 | const handleNavToggle = useCallback(() => setCollapsed((c) => !c), []); 17 | 18 | useEffect(() => { 19 | setCollapsed(true); 20 | }, [location]); 21 | 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | {[ 30 | ...literalRoutes.map((route) => ( 31 | 32 | 33 | 34 | )), 35 | 36 | 37 | , 38 | ]} 39 | 40 | 41 | 42 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | const Content = styled.main` 54 | overflow: auto; 55 | flex: 1 1 auto; 56 | flex-direction: column; 57 | box-sizing: border-box; 58 | padding: 0 ${scale(0)}; 59 | padding-bottom: ${scale(6)} !important; 60 | 61 | @media (min-width: 400px) { 62 | padding: 0 ${scale(1)}; 63 | } 64 | 65 | @media (min-width: 600px) { 66 | padding: 0 ${scale(2)}; 67 | } 68 | 69 | h1 { 70 | font-size: ${scale(3)}; 71 | margin-top: ${scale(5)}; 72 | margin-bottom: ${scale(2)}; 73 | } 74 | 75 | h2 { 76 | font-size: ${scale(2)}; 77 | margin-top: ${scale(4)}; 78 | margin-bottom: ${scale(1)}; 79 | } 80 | 81 | h3 { 82 | font-size: ${scale(1)}; 83 | margin-top: ${scale(1)}; 84 | margin-bottom: ${scale(0)}; 85 | } 86 | 87 | p { 88 | font-size: ${scale(0)}; 89 | margin: ${scale(1)} 0; 90 | line-height: 2em; 91 | color: #222; 92 | } 93 | 94 | li { 95 | line-height: 2em; 96 | 97 | & + & { 98 | margin-top: ${scale(0)}; 99 | } 100 | } 101 | 102 | blockquote { 103 | border-left: solid 3px; 104 | margin-left: ${scale(0)}; 105 | padding-left: ${scale(0)}; 106 | } 107 | 108 | *:not(pre) > code { 109 | color: #e36975; 110 | } 111 | 112 | pre[class*='language-'] { 113 | padding: 0; 114 | } 115 | 116 | pre > code { 117 | display: block; 118 | overflow: auto; 119 | padding: ${scale(1)}; 120 | 121 | @media (min-width: 600px) { 122 | padding: ${scale(2)}; 123 | } 124 | } 125 | 126 | *:not(h1):not(h2):not(h3):not(h4):not(h5) > a { 127 | color: #8b61ff; 128 | } 129 | `; 130 | 131 | const NavButton = styled.img` 132 | height: ${scale(5)}; 133 | position: fixed; 134 | bottom: 30px; 135 | right: 30px; 136 | box-shadow: 0 0 0 rgba(0, 0, 0, 0.25); 137 | border-radius: 50%; 138 | background: #000; 139 | cursor: pointer; 140 | z-index: 5; 141 | 142 | @media (min-width: 1000px) { 143 | display: none; 144 | } 145 | `; 146 | 147 | const AppRoute: FC = ({ 148 | title, 149 | component: Component, 150 | metadata, 151 | }) => { 152 | useHead({ 153 | title: `${title} | Fielder Docs`, 154 | metas: [ 155 | ...(metadata || []), 156 | { name: 'og:type', content: 'website' }, 157 | { 158 | name: 'og:image', 159 | content: `https://fielder.andyrichardson.dev${ogImage}`, 160 | }, 161 | { 162 | name: 'twitter:card', 163 | content: 'summary_large_image', 164 | }, 165 | { 166 | name: 'twitter:site', 167 | content: '@andyrichardsonn', 168 | }, 169 | { 170 | name: 'twitter:creator', 171 | content: '@andyrichardsonn', 172 | }, 173 | { 174 | name: 'twitter:image', 175 | content: `https://fielder.andyrichardson.dev${ogImage}`, 176 | }, 177 | ], 178 | }); 179 | 180 | // Use SSR for meta tags only (no hydration) 181 | if (navigator.userAgent === 'ReactSnap') { 182 | return null; 183 | } 184 | 185 | return ( 186 | 187 | 188 | 189 | ); 190 | }; 191 | -------------------------------------------------------------------------------- /docs/src/assets/OpenGraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andyrichardson/fielder/07e6ec1062657c1122fc29b36464dc31b41af3e8/docs/src/assets/OpenGraph.png -------------------------------------------------------------------------------- /docs/src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/src/assets/maskable-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/src/assets/nav-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/src/assets/readme-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live'; 3 | import { css } from '@linaria/core'; 4 | import { scale } from '../scale'; 5 | 6 | export const Editor = ({ code, scope }: { code: string; scope: any }) => ( 7 | <> 8 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | const transformCode = (code: any) => code.replace(/import.*;/g, ''); 25 | 26 | const theme = { 27 | plain: { 28 | fontFamily: '"Source Code Pro", monospace', 29 | color: '#90a4ae', 30 | }, 31 | styles: [ 32 | { 33 | types: ['keyword'], 34 | style: { 35 | color: '#bd93f9', 36 | }, 37 | }, 38 | { 39 | types: [ 40 | 'atrule', 41 | 'boolean', 42 | 'constant', 43 | 'function', 44 | 'id', 45 | 'important', 46 | 'keyword', 47 | 'symbol', 48 | ], 49 | style: { 50 | color: '#7c4dff', 51 | }, 52 | }, 53 | { 54 | types: [ 55 | 'operator', 56 | 'property', 57 | 'punctuation', 58 | 'attr-name', 59 | 'builtin', 60 | 'cdata', 61 | 'char', 62 | 'class', 63 | 'inserted', 64 | ], 65 | style: { 66 | color: '#39adb5', 67 | }, 68 | }, 69 | { 70 | types: ['tag', 'url', 'variable', 'deleted', 'entity', 'selector'], 71 | style: { 72 | color: '#e53935', 73 | }, 74 | }, 75 | { 76 | types: [ 77 | 'attr-value', 78 | 'attribute', 79 | 'psuedo-element', 80 | 'psuedo-class', 81 | 'string', 82 | ], 83 | style: { 84 | color: '#f6a434', 85 | }, 86 | }, 87 | ], 88 | }; 89 | 90 | // Cast to any because of incorrect type defs on lib 91 | // (missing spellcheck attr) 92 | const editorStyle = css` 93 | margin-top: ${scale(1)}; 94 | padding: 0; 95 | background: #fafafa; 96 | 97 | & > * { 98 | padding: 28px !important; 99 | } 100 | `; 101 | 102 | const previewStyle = css` 103 | display: flex; 104 | justify-content: center; 105 | padding: ${scale(1)} 0; 106 | 107 | form { 108 | padding: ${scale(2)}; 109 | border: solid 2px; 110 | } 111 | 112 | .field { 113 | margin: ${scale(1)} 0; 114 | 115 | &.column { 116 | flex-direction: column; 117 | align-items: unset; 118 | } 119 | } 120 | 121 | .field:first-child { 122 | margin-top: 0; 123 | } 124 | 125 | .field label { 126 | font-weight: bold; 127 | display: inline-block; 128 | width: ${scale(7)}; 129 | } 130 | 131 | .field input[type='text'], 132 | .field input[type='password'], 133 | .field input[type='number'], 134 | .field select { 135 | width: 200px; 136 | font-family: Inter, sans-serif; 137 | font-weight: 600; 138 | font-size: ${scale(0)}; 139 | border: solid 2px; 140 | padding: ${scale(-6)} ${scale(-4)}; 141 | 142 | &:focus { 143 | outline: none; 144 | } 145 | } 146 | 147 | .field input[type='checkbox'] { 148 | margin: 0; 149 | margin-left: ${scale(1)}; 150 | margin-right: ${scale(0)}; 151 | width: ${scale(1)}; 152 | height: ${scale(1)}; 153 | appearance: none; 154 | outline: solid 2px; 155 | outline-offset: 0; 156 | border: solid 2px #fff; 157 | } 158 | 159 | .field input:checked { 160 | background: #000; 161 | } 162 | 163 | button.primary { 164 | margin: 0 auto; 165 | font-size: ${scale(0)}; 166 | font-weight: bold; 167 | padding: ${scale(-4)}; 168 | background: transparent; 169 | border: solid 2px; 170 | float: right; 171 | 172 | &:focus { 173 | outline: none; 174 | } 175 | } 176 | `; 177 | 178 | export default Editor; 179 | -------------------------------------------------------------------------------- /docs/src/components/HeadingLink.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import React from 'react'; 3 | import { styled } from '@linaria/react'; 4 | 5 | const withLink = (Element: 'h1' | 'h2' | 'h3' | 'h4' | 'h5') => ({ 6 | id, 7 | ...props 8 | }: any) => ( 9 | 10 | 11 | {props.children} 12 | 13 | 14 | ); 15 | 16 | export const h1 = withLink('h1'); 17 | export const h2 = withLink('h2'); 18 | export const h3 = withLink('h3'); 19 | export const h4 = withLink('h4'); 20 | export const h5 = withLink('h5'); 21 | 22 | const AnchorLink = styled.a` 23 | color: initial; 24 | text-decoration: initial; 25 | `; 26 | -------------------------------------------------------------------------------- /docs/src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@linaria/react'; 2 | import React, { ComponentProps, FC } from 'react'; 3 | import { routes } from '../routes'; 4 | import Icon from '../assets/icon.svg'; 5 | import { Link, useLocation } from 'wouter'; 6 | import { scale } from '../scale'; 7 | 8 | export const Navigation: FC> = (props) => { 9 | const [location] = useLocation(); 10 | 11 | return ( 12 | 44 | ); 45 | }; 46 | 47 | const Nav = styled.div` 48 | box-sizing: border-box; 49 | position: sticky; 50 | height: min-content; 51 | top: 0; 52 | display: flex; 53 | justify-content: center; 54 | padding: 0 ${scale(2)}; 55 | padding-bottom: ${scale(2)}; 56 | 57 | @media (max-width: 999px) { 58 | background: #fff; 59 | z-index: 1; 60 | height: 100%; 61 | position: fixed; 62 | width: 100vw; 63 | min-width: 100vw; 64 | margin-left: 0; 65 | transition: margin-left 200ms ease; 66 | overflow: auto; 67 | 68 | &[data-collapsed] { 69 | margin-left: -100vw; 70 | } 71 | } 72 | 73 | @media (min-width: 1000px) { 74 | width: 200px; 75 | } 76 | `; 77 | 78 | const NavContent = styled.nav` 79 | display: flex; 80 | flex-direction: column; 81 | height: max-content; 82 | width: 300px; 83 | `; 84 | 85 | const Logo = styled.img` 86 | width: 100%; 87 | margin-top: 60px; 88 | margin-bottom: ${scale(2)}; 89 | overflow: visible; 90 | `; 91 | 92 | const ParentLink = styled.a` 93 | padding: ${scale(-1)} 0; 94 | text-decoration: none; 95 | font-size: ${scale(1)}; 96 | color: #000; 97 | font-weight: bold; 98 | `; 99 | 100 | const ChildLink = styled.a` 101 | text-decoration: none; 102 | font-size: ${scale(0)}; 103 | color: #000; 104 | padding: ${scale(-2)} 0; 105 | padding-left: ${scale(-2)}; 106 | 107 | &[data-active='true'] { 108 | color: #e36975; 109 | } 110 | `; 111 | -------------------------------------------------------------------------------- /docs/src/components/examples/dynamic-forms.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, useCallback } from 'react'; 2 | import { Editor } from '../Editor'; 3 | import * as fielderImports from 'fielder'; 4 | 5 | const code = `\ 6 | import { useForm, useField, useSubmit, FielderProvider } from "fielder"; 7 | 8 | const Form = () => { 9 | const [step, setStep] = useState(0); 10 | const form = useForm(); 11 | 12 | const content = useMemo(() => { 13 | if (step === 0) { 14 | return setStep(1)} />; 15 | } 16 | 17 | console.log(form.fields); 18 | 19 | if (form.fields.region.value === "UK") { 20 | return ; 21 | } 22 | 23 | return ; 24 | }, [form.fields, step]); 25 | 26 | return {content}; 27 | }; 28 | 29 | const RegionSelect = ({ onComplete }) => { 30 | const [regionProps, regionMeta] = useField({ 31 | name: "region", 32 | initialValue: "UK", 33 | }); 34 | 35 | return ( 36 |
37 |
38 | 39 | 43 | {conditionalError(regionMeta)} 44 |
45 | 46 | 49 |
50 | ); 51 | }; 52 | 53 | const USSubForm = () => { 54 | const [ageProps, ageMeta] = useField({ 55 | name: "age", 56 | initialValue: "18", 57 | validate: useCallback(({ value }) => { 58 | if (value < 18) { 59 | throw Error("Age must be over 18"); 60 | } 61 | }, []), 62 | }); 63 | 64 | const { handleSubmit } = useSubmit((values) => 65 | alert(\`Submitted: \${JSON.stringify(values, null, 2)}\`) 66 | ); 67 | 68 | return ( 69 |
70 |
71 | 72 | 73 | {conditionalError(ageMeta)} 74 |
75 | 76 | 79 |
80 | ); 81 | }; 82 | 83 | const UKSubForm = () => { 84 | const [nameProps, nameMeta] = useField({ 85 | name: "name", 86 | initialValue: "UK Citizen", 87 | }); 88 | const [termsProps, termsMeta] = useField({ 89 | name: "ukTerms", 90 | initialValue: [], 91 | validate: useCallback(({ value }) => { 92 | if (!value.includes("legal")) { 93 | throw Error("Legal terms must be accepted"); 94 | } 95 | }, []), 96 | }); 97 | 98 | const checkboxes = useMemo( 99 | () => [ 100 | { label: "Send me marketing mail", value: "marketing" }, 101 | { label: "I accept terms", value: "legal" }, 102 | ], 103 | [] 104 | ); 105 | 106 | const { handleSubmit } = useSubmit((values) => 107 | alert(\`Submitted: \${JSON.stringify(values, null, 2)}\`) 108 | ); 109 | 110 | return ( 111 |
112 |
113 | 114 | 115 |
116 | 117 |
118 | 119 | {checkboxes.map(({ label, value }) => ( 120 |
121 | 127 | {label} 128 |
129 | ))} 130 | {conditionalError(termsMeta)} 131 |
132 | 133 | 136 |
137 | ); 138 | }; 139 | 140 | const conditionalError = (meta) => meta.error &&

{meta.error}

; 141 | 142 | // Render this live example 143 | render(
); 144 | `; 145 | 146 | const scope = { 147 | ...fielderImports, 148 | useState, 149 | useMemo, 150 | useCallback, 151 | }; 152 | 153 | export const Example = () => ; 154 | -------------------------------------------------------------------------------- /docs/src/components/examples/static-forms.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as fielderImports from 'fielder'; 3 | import { Editor } from '../Editor'; 4 | 5 | const code = `\ 6 | import { useForm, useField, useSubmit, FielderProvider } from "fielder"; 7 | 8 | const Form = () => { 9 | const form = useForm(); 10 | 11 | return ( 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | const FormContent = () => { 19 | const [usernameProps, usernameMeta] = useField({ 20 | name: "username", 21 | initialValue: "", 22 | validate: usernameValidation, 23 | }); 24 | const [passwordProps, passwordMeta] = useField({ 25 | name: "password", 26 | initialValue: "", 27 | validate: passwordValidation, 28 | }); 29 | 30 | const { handleSubmit } = useSubmit((values) => 31 | alert(\`Submitted: \${JSON.stringify(values, null, 2)}\`) 32 | ); 33 | 34 | return ( 35 | 36 |
37 | 38 | 39 | {conditionalError(usernameMeta)} 40 |
41 | 42 |
43 | 44 | 45 | {conditionalError(passwordMeta)} 46 |
47 | 48 | 51 | 52 | ); 53 | }; 54 | 55 | const usernameValidation = ({ value }) => { 56 | if (!value) { 57 | throw Error("Username is required."); 58 | } 59 | 60 | if (value.length < 4) { 61 | throw Error("Username must be at least 4 characters."); 62 | } 63 | }; 64 | 65 | const passwordValidation = ({ value }) => { 66 | if (!value) { 67 | throw Error("Password is required."); 68 | } 69 | 70 | if (value.length < 4) { 71 | throw Error("Password must be at least 4 characters."); 72 | } 73 | }; 74 | 75 | const conditionalError = (meta) => 76 | meta.hasBlurred && meta.error &&

{meta.error}

; 77 | 78 | // Render this live example 79 | render(
); 80 | 81 | `; 82 | 83 | const scope = { 84 | ...fielderImports, 85 | }; 86 | 87 | export const Example = () => ; 88 | -------------------------------------------------------------------------------- /docs/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.mdx' { 2 | const Component: import('react').FC; 3 | export default Component; 4 | } 5 | 6 | declare module '*.png' { 7 | const url: string; 8 | export default string; 9 | } 10 | 11 | declare module '*.svg' { 12 | const url: string; 13 | export default string; 14 | } 15 | -------------------------------------------------------------------------------- /docs/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'preact/debug'; 2 | import { css } from '@linaria/core'; 3 | import React from 'react'; 4 | import { render } from 'react-dom'; 5 | import { App } from './App'; 6 | import { scale } from './scale'; 7 | 8 | const rootElement = document.getElementById('root'); 9 | render(, rootElement); 10 | 11 | export function register() { 12 | if (!('serviceWorker' in navigator)) { 13 | return; 14 | } 15 | 16 | navigator.serviceWorker 17 | .register(`/service-worker.js`, { scope: '/' }) 18 | .then((reg) => { 19 | // Check for new service worker on 20 | // worker already installed and page refresh 21 | if (!reg.installing) { 22 | reg.update().catch(() => {}); // Update service worker on refresh 23 | } 24 | 25 | // Check for a new service worker every minute 26 | window.setInterval(() => reg.update().catch(() => {}), 1000 * 60); 27 | }); 28 | } 29 | register(); 30 | 31 | export const globals = css` 32 | :global() { 33 | @import '@fontsource/inter'; 34 | @import '@fontsource/source-code-pro'; 35 | @import 'prism-themes/themes/prism-material-light.css'; 36 | 37 | html, 38 | body { 39 | margin: 0; 40 | font-family: 'Inter', system, sans-serif; 41 | font-size: ${scale(0)}; 42 | } 43 | 44 | #root { 45 | display: flex; 46 | margin: 0 auto; 47 | width: 100%; 48 | max-width: 100%; 49 | 50 | @media (min-width: 1000px) { 51 | width: 1000px; 52 | max-width: 1000px; 53 | } 54 | } 55 | 56 | pre > code, 57 | code { 58 | font-family: 'Source Code Pro', monospace; 59 | } 60 | 61 | h1, 62 | h2, 63 | h3, 64 | h4 { 65 | font-weight: 600; 66 | } 67 | } 68 | `; 69 | -------------------------------------------------------------------------------- /docs/src/pages/About.mdx: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | _Fielder_ is a form library for React and React Native that has been built from the ground up with a [field-first approach to validation](https://dev.to/andyrichardsonn/why-we-need-another-form-library-fielder-4eah). 4 | 5 | ## Features 6 | 7 | - Synchronous validation - _no cascading renders_ 8 | - Validation events - _validation can differ per event (change, blur, submit, etc.)_ 9 | - Hooks that work - _hooks respond to validation changes_ 10 | - Evolving schemas - _validation logic evolves with the UI_ 11 | 12 | ## Basic usage 13 | 14 | ### Install Fielder 15 | 16 | Add Fielder to your project. 17 | 18 | ```sh 19 | npm i fielder 20 | ``` 21 | 22 | ### Import the module 23 | 24 | Use `fielder` or `fielder/preact`. 25 | 26 | ```tsx 27 | // React 28 | import { useForm, ... } from 'fielder'; 29 | 30 | // Preact 31 | import { useForm, ... } from 'fielder/preact'; 32 | ``` 33 | 34 | ### Set up a form 35 | 36 | Use the `useForm` hook to create a form. 37 | 38 | ```tsx 39 | const myForm = useForm(); 40 | 41 | return {children}; 42 | ``` 43 | 44 | ### Create some fields 45 | 46 | Use the `useField` hook to create a field. 47 | 48 | ```tsx 49 | const [usernameProps, usernameMeta] = useField({ 50 | name: 'username', 51 | initialValue: '', 52 | validate: useCallback(({ value }) => { 53 | if (!value) { 54 | throw Error('Username is required!'); 55 | } 56 | }, []), 57 | }); 58 | 59 | return ( 60 | <> 61 | 62 | {usernameMeta.error} 63 | 64 | ); 65 | ``` 66 | 67 | ### Additional info 68 | 69 | Once you're all set up, be sure to check out [the guides](http://fielder.andyrichardson.dev/guides/getting-started) for a deeper dive! 70 | -------------------------------------------------------------------------------- /docs/src/pages/api/FielderProvider.mdx: -------------------------------------------------------------------------------- 1 | # FielderProvider 2 | 3 | _Exposes a form instance [(FormState)](/api/useform#formstate) via React context._ 4 | 5 | ```tsx 6 | import { FielderProvider } from 'fielder'; 7 | ``` 8 | 9 | ## Example Usage 10 | 11 | ```tsx 12 | const formState = useForm(); 13 | 14 | return {children}; 15 | ``` 16 | 17 | ## Props 18 | 19 | ### value **(required)** 20 | 21 | The [(FormState)](/api/useform#formstate) to be shared. 22 | 23 | Type: `FormState` 24 | -------------------------------------------------------------------------------- /docs/src/pages/api/useField.mdx: -------------------------------------------------------------------------------- 1 | # useField 2 | 3 | _Hook to mount a field to the form._ 4 | 5 | ```tsx 6 | import { useField } from 'fielder'; 7 | ``` 8 | 9 | ## Example usage 10 | 11 | ```tsx 12 | const [passwordProps, passwordMeta] = useField({ 13 | name: 'password', 14 | validate: passwordValidation, 15 | }); 16 | 17 | return ; 18 | ``` 19 | 20 | ## Return type 21 | 22 | An array containing props for the target component [(UseFieldProps)](#usefieldprops) and metadata [(UseFieldMeta)](#usefieldmeta). 23 | 24 | Type: `[UseFieldProps, UseFieldMeta]` 25 | 26 | ## Arguments 27 | 28 | _useField_ takes a single object with the following properties: 29 | 30 | ### name **(required)** 31 | 32 | The name/key of the field to be added to the form state. 33 | 34 | Type: `string` 35 | 36 | Example: `'password'` 37 | 38 | ### initialValue **(required)** 39 | 40 | The starting value of the field on first mount. 41 | 42 | Type: `string | number | boolean | string[]` 43 | 44 | Example: `'small'` 45 | 46 | ### validate 47 | 48 | A validation function which throws an error when validation has failed. 49 | 50 | Type: `(arg: { value: T, form: F, trigger: ValidationTrigger }) => (void | Promise)` 51 | 52 | Default: `undefined` 53 | 54 | Example: 55 | 56 | ```js 57 | (v, f) => { 58 | if (v < f.otherField.value) { 59 | throw Error("Value must be more than 'from' value") 60 | } 61 | }` 62 | ``` 63 | 64 | ### destroyOnUnmount 65 | 66 | Whether the field should be completely removed from the form state on unmount. 67 | 68 | Type: `boolean` 69 | 70 | Default: `false` 71 | 72 | ## Types 73 | 74 | ### UseFieldProps 75 | 76 | Props which can be passed to a form element / component. 77 | 78 | ```ts 79 | type UseFieldProps = { 80 | readonly name: string; 81 | readonly value: T; 82 | readonly onChange: ChangeEventHandler | (value: T) => void; 83 | readonly onBlur: () => void; 84 | }; 85 | ``` 86 | 87 | ### UseFieldMeta 88 | 89 | Additional information about a field and it's validation state. 90 | 91 | ```ts 92 | type UseFieldMeta = { 93 | /** Validation error. */ 94 | readonly error?: Error | string; 95 | /** Valid state. */ 96 | readonly isValid: boolean; 97 | /** Async validation is in progress. */ 98 | readonly isValidating: boolean; 99 | /** onBlur has been called since mount/remount. */ 100 | readonly hasBlurred: boolean; 101 | /** onChange has been called since mount/remount. */ 102 | readonly hasChanged: boolean; 103 | }; 104 | ``` 105 | 106 | ### ValidationTrigger 107 | 108 | An event type which has triggered validation. 109 | 110 | ```ts 111 | type ValidationTrigger = 112 | /* Field has been mounted */ 113 | | 'mount' 114 | /* `onBlur` event called on field */ 115 | | 'blur' 116 | /* `onChange` event called on field */ 117 | | 'change' 118 | /* The value of another field in the form has changed */ 119 | | 'update' 120 | /* Submission has begun */ 121 | | 'submit'; 122 | ``` 123 | -------------------------------------------------------------------------------- /docs/src/pages/api/useForm.mdx: -------------------------------------------------------------------------------- 1 | # useForm 2 | 3 | _Creates a new form instance._ 4 | 5 | ```tsx 6 | import { useForm } from 'fielder'; 7 | ``` 8 | 9 | ## Example Usage 10 | 11 | ```tsx 12 | const formState = useForm(); 13 | 14 | return {children}; 15 | ``` 16 | 17 | ## Return type 18 | 19 | The state of the form [(FormState)](#formstate) along with accessors and mutators. 20 | 21 | Type: `FormState` 22 | 23 | ## Arguments 24 | 25 | _useForm_ optionally takes a single object with the following properties: 26 | 27 | ### fromState 28 | 29 | A key value store of field names and their values, for restoring form state on initial render. 30 | 31 | Type: `object` 32 | 33 | Example: `{ name: "some name", someField: "1234" }` 34 | 35 | ## Types 36 | 37 | ### FormState 38 | 39 | The state of the whole form along with accessors and mutators. 40 | 41 | ```ts 42 | export interface FormState = any> { 43 | fields: Record; 44 | isValid: boolean; 45 | isValidating: boolean; 46 | setFieldValue: (a: SetFieldValueArgs) => void; 47 | blurField: (a: BlurFieldArgs) => void; 48 | validateField: (a: { name: string }) => void; 49 | 50 | // Internals 51 | premountField: (a: MountFieldArgs) => void; 52 | mountField: (a: MountFieldArgs) => void; 53 | unmountField: (a: UnmountFieldArgs) => void; 54 | setFieldState: (a: SetFieldStateArgs) 55 | } 56 | ``` 57 | 58 | ### FieldState 59 | 60 | The state of an individual field (including meta information). 61 | 62 | ```tsx 63 | export interface FieldState { 64 | // Internals 65 | readonly _isActive: boolean; 66 | readonly _validate: FieldConfig['validate']; 67 | 68 | // Props 69 | readonly name: string; 70 | readonly value?: T; 71 | 72 | // Meta 73 | readonly error?: FormError; 74 | readonly isValid: boolean; 75 | readonly isValidating: boolean; 76 | readonly hasBlurred: boolean; 77 | readonly hasChanged: boolean; 78 | } 79 | ``` 80 | -------------------------------------------------------------------------------- /docs/src/pages/api/useFormContext.mdx: -------------------------------------------------------------------------------- 1 | # useFormContext 2 | 3 | _Retrieves form state exposed via context._ 4 | 5 | ```tsx 6 | import { useFormContext } from 'fielder'; 7 | ``` 8 | 9 | ## Example Usage 10 | 11 | ```tsx 12 | const formState = useFormContext(); 13 | 14 | return ; 15 | ``` 16 | 17 | ## Response 18 | 19 | The state of the form [(FormState)](/api/useform#formstate) along with accessors and mutators. 20 | 21 | Type; `FormState` 22 | 23 | > See [useForm documentation](/api/useform) for more information about _FormState_ 24 | 25 | ## Arguments 26 | 27 | `useForm` doesn't currently take any arguments. 28 | -------------------------------------------------------------------------------- /docs/src/pages/api/useSubmit.mdx: -------------------------------------------------------------------------------- 1 | # useSubmit 2 | 3 | _Hook to guard submission logic and trigger a submission [validation event](/guides/validation#validation-events)._ 4 | 5 | ```tsx 6 | import { useSubmit } from 'fielder'; 7 | ``` 8 | 9 | ## Example usage 10 | 11 | ```tsx 12 | const { handleSubmit } = useSubmit((values) => { 13 | fetch('/submit-form', { 14 | method: 'POST', 15 | body: JSON.stringify(values), 16 | }); 17 | }); 18 | 19 | return ; 20 | ``` 21 | 22 | ## Return type 23 | 24 | An object containing the following properties: 25 | 26 | ### isValidating 27 | 28 | Indicator of whether async `submit` validation event is in progress. 29 | 30 | Type: `boolean` 31 | 32 | ### hasSubmitted 33 | 34 | Indicator of whether `handleSubmit` function has been called. 35 | 36 | Type: `boolean` 37 | 38 | ### handleSubmit 39 | 40 | Guarded submit function which triggers `submit` validation on call. 41 | 42 | Type: `() => void` 43 | 44 | ## Arguments 45 | 46 | _useSubmit_ takes a single function as an argument. 47 | 48 | ### fn **(required)** 49 | 50 | The function to be called on submission validation success. 51 | 52 | Type: `(f: Record) => void` 53 | 54 | Example: `(values) => console.log(values)` 55 | 56 | ## Types 57 | 58 | ### UseSubmitResponse 59 | 60 | Return type of a `useSubmit` call. 61 | 62 | ```ts 63 | type UseSubmitResponse = { 64 | /** Guarded submit function which triggers `submit` validation on call. */ 65 | handleSubmit: () => void; 66 | /** Indicates if async fetching is in progress. */ 67 | isValidating: boolean; 68 | /** Indicates if `handleSubmit` has been called. */ 69 | hasSubmitted: boolean; 70 | }; 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/src/pages/examples/dynamic-forms.mdx: -------------------------------------------------------------------------------- 1 | import { Example } from '../../components/examples/dynamic-forms'; 2 | 3 | # Dynamic forms 4 | 5 | Here's a basic login form, try enter some fake values and see how validation works. 6 | 7 | > The code below can be live-edited 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/src/pages/examples/static-forms.mdx: -------------------------------------------------------------------------------- 1 | import { Example } from '../../components/examples/static-forms'; 2 | 3 | # Static forms 4 | 5 | Here's a basic login form, try enter some fake values to see how validation works. 6 | 7 | > Feel free to make changes to the code below! 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/src/pages/guides/getting-started.mdx: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | _Creating a form and fields with `Fielder`._ 4 | 5 | ## Creating a form 6 | 7 | Every form starts in the same way - a root "form" component to: 8 | 9 | - Create an instance of `Fielder` 10 | - Expose that instance to any child components 11 | 12 | ```tsx 13 | import React, { useEffect } from 'react'; 14 | import { useForm, FielderProvider } from 'fielder'; 15 | 16 | const LoginForm = ({ children }) => { 17 | const formState = useForm(); 18 | 19 | // Example of reacting to form changes 20 | useEffect(() => { 21 | console.log('Form state has changed!', formState); 22 | }, [formState]); 23 | 24 | return {children}; 25 | }; 26 | ``` 27 | 28 | The use of `FielderProvider` ensures that the hooks we use later can access the form state. 29 | 30 | > Forms can be a static section on a page, or a dynamic collection of pages (i.e. steppers/wizards/multi-step). 31 | 32 | ## Adding some fields 33 | 34 | Next we want to add some fields to our form. 35 | 36 | > It's important to remember that fielder treats fields as dynamic entities. This means they can be added, removed, and/or changed at any time. 37 | 38 | ```tsx 39 | const MyFormContent = () => { 40 | const [usernameProps] = useField({ 41 | name: 'username', 42 | initialValue: '', 43 | }); 44 | const [passwordProps] = useField({ 45 | name: 'password', 46 | initialValue: '', 47 | }); 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | ``` 57 | 58 | ## Checkbox & Radio inputs 59 | 60 | Checkbox and radio inputs are different to all other field types as their state is declared by the `checked` 61 | attribute rather than `value` ([more info here](https://github.com/andyrichardson/fielder/issues/23#issuecomment-576352847)). 62 | 63 | ### Radio buttons 64 | 65 | Using radio buttons is similar to using text fields, with two exceptions: 66 | 67 | - The `value` attribute is static 68 | - The `checked` attribute needs to be added (see below) 69 | 70 | ```tsx 71 | const [hobbiesProps] = useField({ 72 | name: 'hobbies', 73 | initialValue: 'sports', 74 | }); 75 | 76 | const radioButtons = useMemo(() => [ 77 | { label: "Sports", value: "sports" }, 78 | { label: "Coding", value: "coding" }, 79 | { label: "Other", value: "other" }, 80 | ], []); 81 | 82 | return ( 83 | <> 84 |

What is your favorite hobby?

85 | {radioButtons.map(({ label, value }) => ( 86 | 93 | {label} 94 | ))} 95 | 96 | ) 97 | ``` 98 | 99 | ### Checkbox groups 100 | 101 | Checkbox groups are a means for storing multiple checkbox selections in a single field. 102 | 103 | These are almost identical to radio buttons - the only difference being their value is an array corresponding to the selected values. 104 | 105 | ```tsx 106 | const [hobbiesProps] = useField({ 107 | name: 'hobbies', 108 | initialValue: ['sports'], 109 | }); 110 | 111 | const checkboxOptions = useMemo(() => [ 112 | { label: "Sports", value: "sports" }, 113 | { label: "Coding", value: "coding" }, 114 | { label: "Other", value: "other" }, 115 | ], []); 116 | 117 | return ( 118 | <> 119 |

What are your hobbies?

120 | {checkboxOptions.map(({ label, value }) => ( 121 | 128 | {label} 129 | ))} 130 | 131 | ) 132 | ``` 133 | 134 | ### Checkboxes (individual) 135 | 136 | If you prefer to have a field for each checkbox, that's also an option. 137 | 138 | Be sure to set the initial value of the field to boolean. 139 | 140 | ```tsx 141 | const [acceptProps] = useField({ 142 | name: 'accept', 143 | initialValue: false, 144 | }); 145 | 146 | return ( 147 | <> 148 |

Do you accept these terms?

149 | 150 | 151 | ); 152 | ``` 153 | -------------------------------------------------------------------------------- /docs/src/pages/guides/react-native.mdx: -------------------------------------------------------------------------------- 1 | # React Native 2 | 3 | _Using `Fielder` with React Native projects._ 4 | 5 | ## Differences from web 6 | 7 | The API is identical with the only limitations being dependent on your component library. 8 | 9 | Unlike in web, most popular React Native form component's don't adhere to a prop naming standard (i.e. _onChange_, _onBlur_, _value_, etc). 10 | Because of this, you'll need to proxy the provided [UseFieldProps](../api/usefield/#usefieldprops) to work with your components. 11 | 12 | ```tsx 13 | const [fieldProps, fieldMeta] = useField({ name: 'firstName' }); 14 | 15 | return ( 16 | // React (web) 17 | 18 | // React Native 19 | // TextInput change prop is called 'onChangeText' 20 | ); 21 | ``` 22 | 23 | ### Scaling this up 24 | 25 | It might be worth creating your own _useField_ proxy hooks if you find yourself having to proxy props frequently. 26 | 27 | ```tsx 28 | export const useTextField = (...args: Parameters) => { 29 | const [fieldProps, fieldMeta] = useField(..args); 30 | 31 | return useMemo(() => [ 32 | { ...fieldProps, onChangeText: fieldProps.onChange }, // Apply needed transforms here 33 | fieldMeta, 34 | ], [fieldProps, fieldMeta]); 35 | }; 36 | ``` 37 | 38 | ## Examples 39 | 40 | Check out the [React Native example repo](https://github.com/andyrichardson/fielder/tree/master/examples/4-native). 41 | -------------------------------------------------------------------------------- /docs/src/pages/guides/submission.mdx: -------------------------------------------------------------------------------- 1 | # Submission 2 | 3 | _Handling submission and/or progression in a form._ 4 | 5 | ## Basic submission 6 | 7 | The `useSubmit` hook can be used to guard submission logic and trigger `submit` validation. 8 | 9 | > The provided function will not be called until `submit` validation succeeds. 10 | 11 | ```tsx 12 | const { handleSubmit } = useSubmit((values) => { 13 | fetch('/submit-form', { 14 | method: 'POST', 15 | body: JSON.stringify(values), 16 | }); 17 | }); 18 | 19 | return ; 20 | ``` 21 | 22 | ## Submission state 23 | 24 | The `useSubmit` hook also provides state indicating whether async submit logic is in progress and whether the provided `handleSubmit` function has been called. 25 | 26 | ```tsx 27 | const [fieldProps, fieldMeta] = useField(/* ... */); 28 | const { isValidating, hasSubmitted } = useSubmit(/* ... */); 29 | 30 | return ( 31 |
32 | 33 | {hasSubmitted && fieldMeta.error} 34 | ; 35 |
36 | ); 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/src/pages/guides/type-safety.mdx: -------------------------------------------------------------------------------- 1 | # Type safety 2 | 3 | _Typing fields and forms with `Fielder`._ 4 | 5 | ## Why use types 6 | 7 | By using types with Fielder, you can: 8 | 9 | - Enforce field value types (setters and return values) 10 | - Enforce field names (prevent typos) 11 | 12 | ## Basic typing 13 | 14 | A _form definition_ type declares all the possible field names and their values. 15 | 16 | ```ts 17 | type MyFormState = { 18 | username: string; 19 | password: string; 20 | saveCredentials: boolean; 21 | }; 22 | ``` 23 | 24 | Passing the _form defintion_ to `useField`, `useForm`, `useFormContext`, or `useSubmit` will ensure the correct types. 25 | 26 | ```tsx 27 | const [fieldProps] = useField({ name: 'password' }); 28 | ``` 29 | 30 | ## Advanced typing 31 | 32 | If you prefer not to manually type every hook call, you can re-export them. 33 | 34 | > You'll want to follow this process for each form you create. 35 | 36 | Create a `fielder.ts` file alongside your form's root component and copy the example below. 37 | 38 | ```ts 39 | import { typedHooks } from 'fielder'; 40 | import { FormType } from './types'; 41 | 42 | export const { useField, useForm, useFormContext } = typedHooks(); 43 | ``` 44 | 45 | Now when using any of the _Fielder_ hooks, import them from the file you just created. 46 | 47 | ```tsx 48 | import { useField } from '../fielder'; 49 | 50 | // ... 51 | const [fieldProps] = useField({ 52 | name: 'invalidName', // Type error! 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/src/pages/guides/validation.mdx: -------------------------------------------------------------------------------- 1 | # Validation 2 | 3 | _Validation is one of the key ways in which `Fielder` differentiates itself from other libraries._ 4 | 5 | ## Validation events 6 | 7 | Any provided validation function will be triggered immediately on either of these events: 8 | 9 | - `mount` - on field mount/remount. 10 | - `change` - on value change. 11 | - `blur` - on blur event. 12 | - `update` - on _any_ change event across the form (see [cross form validation](#cross-form-validation)). 13 | - `submit` - on submit handler called. 14 | 15 | ## Basic validation 16 | 17 | Validation is as simple as providing a function to `useField` which is called on each [validation event](#validation-events). 18 | 19 | ```tsx 20 | const MyField = () => { 21 | const [passwordProps, passwordMeta] = useField({ 22 | name: 'password' 23 | validate: validatePassword 24 | }); 25 | 26 | return ( 27 | <> 28 | 29 | {passwordMeta.hasBlurred && passwordMeta.error} 30 | 31 | ); 32 | }; 33 | 34 | const validatePassword = ({ value }) => { 35 | if (!value || value.length < 8) { 36 | throw Error("Password must be 8 characters long"); 37 | } 38 | }; 39 | ``` 40 | 41 | ## Event specific validation 42 | 43 | The `trigger` argument can be used to cater validation to specific events; such as triggering asynchronous validation on submit. 44 | 45 | ```tsx 46 | const validateUsername = ({ trigger, value }) => { 47 | // Ignore other field updates 48 | if (trigger === 'update') { 49 | return; 50 | } 51 | 52 | // Ensure username exists (mount, change, blur, submit) 53 | if (!value) { 54 | throw Error('Username is required'); 55 | } 56 | 57 | // Check if username is taken (submit) 58 | if (trigger === 'submit') { 59 | return isUsernameFree(value).then((isFree) => { 60 | if (!isFree) { 61 | throw Error('Username is already taken'); 62 | } 63 | }); 64 | } 65 | }; 66 | ``` 67 | 68 | ## Cross form validation 69 | 70 | The `form` argument can be used to conditionally validate a field based on other field values in the form. 71 | 72 | ```tsx 73 | const validatePasswordConfirmation = ({ value, form }) => { 74 | // Wait for password validation first 75 | if (!form.password.isValid) { 76 | return; 77 | } 78 | 79 | if (value !== form.password.value) { 80 | throw Error('Password confirmation does not match'); 81 | } 82 | }; 83 | ``` 84 | 85 | ## Cross source validation 86 | 87 | Memoised validation functions dependent on state outside of the form can be used. 88 | 89 | > Changes to validation functions will retrigger the last mount/change/blur event 90 | 91 | ```tsx 92 | const { addressIsRequired } = useSomeExternalState(); 93 | 94 | const [fieldProps, fieldMeta] = useField({ 95 | name: 'address' 96 | validate: useCallback(({ value }) => { 97 | if (addressIsRequired && !value) { 98 | throw Error("Address is required"); 99 | } 100 | }, [addressIsRequired]) 101 | }); 102 | ``` 103 | -------------------------------------------------------------------------------- /docs/src/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Sitemap: https://fielder.andyrichardson.dev/sitemap.xml -------------------------------------------------------------------------------- /docs/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | import { Route } from 'workbox-routing'; 3 | 4 | /** Route containing component */ 5 | export type LiteralRoute = { 6 | title: string; 7 | url: string; 8 | component: ReturnType; 9 | metadata?: Array; 10 | }; 11 | 12 | /** Route not containing component (group or external link) */ 13 | export type AbstractRoute = { 14 | title: string; 15 | url: string; 16 | children?: Array; 17 | external?: boolean; 18 | }; 19 | 20 | export type RouteDef = LiteralRoute | AbstractRoute; 21 | 22 | export const routes: RouteDef[] = [ 23 | { 24 | title: 'About', 25 | url: '/', 26 | component: lazy(() => import('./pages/About.mdx')), 27 | metadata: [ 28 | { 29 | name: 'description', 30 | content: 31 | 'The official docs site for Fielder - the dynamic form library', 32 | }, 33 | ], 34 | }, 35 | { 36 | title: 'Guides', 37 | url: '/guides/getting-started', 38 | children: [ 39 | { 40 | title: 'Getting started', 41 | url: '/guides/getting-started', 42 | component: lazy(() => import('./pages/guides/getting-started.mdx')), 43 | metadata: [ 44 | { name: 'description', content: 'Get started using Fielder' }, 45 | ], 46 | }, 47 | { 48 | title: 'Validation', 49 | url: '/guides/validation', 50 | component: lazy(() => import('./pages/guides/validation.mdx')), 51 | metadata: [ 52 | { 53 | name: 'description', 54 | content: 'Validating forms and fields with Fielder', 55 | }, 56 | ], 57 | }, 58 | { 59 | title: 'Submission', 60 | url: '/guides/submission', 61 | component: lazy(() => import('./pages/guides/submission.mdx')), 62 | metadata: [ 63 | { 64 | name: 'description', 65 | content: 'Handling submission logic in Fielder', 66 | }, 67 | ], 68 | }, 69 | { 70 | title: 'Type safety', 71 | url: '/guides/type-safety', 72 | component: lazy(() => import('./pages/guides/type-safety.mdx')), 73 | metadata: [ 74 | { 75 | name: 'description', 76 | content: 'Using Fielder with Typescript', 77 | }, 78 | ], 79 | }, 80 | { 81 | title: 'React Native', 82 | url: '/guides/react-native', 83 | component: lazy(() => import('./pages/guides/react-native.mdx')), 84 | metadata: [ 85 | { 86 | name: 'description', 87 | content: 'Using Fielder with React Native', 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | { 94 | title: 'Api', 95 | url: '/api/usefield', 96 | children: [ 97 | { 98 | title: 'useField', 99 | url: '/api/usefield', 100 | component: lazy(() => import('./pages/api/useField.mdx')), 101 | metadata: [ 102 | { 103 | name: 'description', 104 | content: 'API reference documentation for the useField hook', 105 | }, 106 | ], 107 | }, 108 | { 109 | title: 'useForm', 110 | url: '/api/useform', 111 | component: lazy(() => import('./pages/api/useForm.mdx')), 112 | metadata: [ 113 | { 114 | name: 'description', 115 | content: 'API reference documentation for the useForm hook', 116 | }, 117 | ], 118 | }, 119 | { 120 | title: 'useFormContext', 121 | url: '/api/useformcontext', 122 | component: lazy(() => import('./pages/api/useFormContext.mdx')), 123 | metadata: [ 124 | { 125 | name: 'description', 126 | content: 'API reference documentation for the useFormContext hook', 127 | }, 128 | ], 129 | }, 130 | { 131 | title: 'useSubmit', 132 | url: '/api/usesubmit', 133 | component: lazy(() => import('./pages/api/useSubmit.mdx')), 134 | metadata: [ 135 | { 136 | name: 'description', 137 | content: 'API reference documentation for the useSubmit hook', 138 | }, 139 | ], 140 | }, 141 | { 142 | title: 'FielderProvider', 143 | url: '/api/fielderprovider', 144 | component: lazy(() => import('./pages/api/FielderProvider.mdx')), 145 | metadata: [ 146 | { 147 | name: 'description', 148 | content: 149 | 'API reference documentation for the FielderProvider component', 150 | }, 151 | ], 152 | }, 153 | ], 154 | }, 155 | { 156 | title: 'Examples', 157 | url: '/examples/static-forms', 158 | children: [ 159 | { 160 | title: 'Static forms', 161 | url: '/examples/static-forms', 162 | component: lazy(() => import('./pages/examples/static-forms.mdx')), 163 | metadata: [ 164 | { 165 | name: 'description', 166 | content: 167 | 'A live sandbox demonstrating how to create static forms in Fielder', 168 | }, 169 | ], 170 | }, 171 | { 172 | title: 'Dynamic forms', 173 | url: '/examples/dynamic-forms', 174 | component: lazy(() => import('./pages/examples/dynamic-forms.mdx')), 175 | metadata: [ 176 | { 177 | name: 'description', 178 | content: 179 | 'A live sandbox demonstrating how to create dynamic forms in Fielder', 180 | }, 181 | ], 182 | }, 183 | ], 184 | }, 185 | { 186 | title: 'GitHub', 187 | url: 'https://github.com/andyrichardson/fielder', 188 | external: true, 189 | }, 190 | ]; 191 | 192 | const getRoutes = (routeList: RouteDef[], parent?: RouteDef): LiteralRoute[] => 193 | routeList.reduce((acc, current) => { 194 | if ('children' in current && current.children) { 195 | return [...acc, ...getRoutes(current.children, current)]; 196 | } 197 | 198 | if (!('component' in current)) { 199 | return acc; 200 | } 201 | 202 | return [ 203 | ...acc, 204 | { 205 | ...current, 206 | metadata: [ 207 | ...(current.metadata || []), 208 | { 209 | name: 'og:title', 210 | content: parent 211 | ? `${current.title} | ${parent.title}` 212 | : current.title, 213 | }, 214 | { 215 | name: 'og:description', 216 | content: 217 | current.metadata?.find((a) => a.name === 'description') 218 | ?.content || 'Fielder docs', 219 | }, 220 | ], 221 | }, 222 | ]; 223 | }, [] as LiteralRoute[]); 224 | 225 | export const literalRoutes = getRoutes(routes); 226 | -------------------------------------------------------------------------------- /docs/src/scale.ts: -------------------------------------------------------------------------------- 1 | import { modularScale } from 'polished'; 2 | 3 | export const scale = (steps: number) => modularScale(steps, '14px', 1.3); 4 | -------------------------------------------------------------------------------- /docs/src/service-worker.tsx: -------------------------------------------------------------------------------- 1 | import { clientsClaim } from 'workbox-core'; 2 | import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; 3 | import { registerRoute } from 'workbox-routing'; 4 | import { StaleWhileRevalidate } from 'workbox-strategies'; 5 | 6 | declare const self: ServiceWorkerGlobalScope; 7 | 8 | self.skipWaiting(); 9 | clientsClaim(); 10 | 11 | precacheAndRoute(self.__WB_MANIFEST); 12 | 13 | registerRoute(({ request, url }) => { 14 | if (request.mode !== 'navigate') { 15 | return false; 16 | } 17 | if (url.pathname.startsWith('/_')) { 18 | return false; 19 | } 20 | // Has file extension 21 | if (url.pathname.match(new RegExp('/[^/?]+\\.[^/]+$'))) { 22 | return false; 23 | } 24 | return true; 25 | }, createHandlerBoundToURL(`/index.html`)); 26 | 27 | registerRoute( 28 | /.*\/manifest\/.*/, 29 | new StaleWhileRevalidate({ 30 | cacheName: 'manifest-v1', 31 | }) 32 | ); 33 | 34 | registerRoute( 35 | /.*\/fonts\/.*/, 36 | new StaleWhileRevalidate({ 37 | cacheName: 'fonts-v1', 38 | }) 39 | ); 40 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "DOM", 8 | "WebWorker" 9 | ] /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 10 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 11 | "strict": true /* Enable all strict type-checking options. */, 12 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 13 | "skipLibCheck": true /* Skip type checking of declaration files. */, 14 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 4 | import HTMLWebpackPlugin from 'html-webpack-plugin'; 5 | import { InjectManifest } from 'workbox-webpack-plugin'; 6 | import TerserPlugin from 'terser-webpack-plugin'; 7 | import FaviconsWebpackPlugin from 'favicons-webpack-plugin'; 8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 9 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 10 | import remarkSlug from 'remark-slug'; 11 | import remarkPrism from 'remark-prism'; 12 | 13 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 14 | 15 | export default { 16 | mode: process.env.NODE_ENV || 'development', 17 | entry: { 18 | app: './src/index.tsx', 19 | }, 20 | output: { 21 | publicPath: '/', 22 | path: path.resolve(__dirname, './dist'), 23 | }, 24 | optimization: { 25 | minimize: true, 26 | minimizer: [new TerserPlugin()], 27 | splitChunks: { 28 | chunks: 'all', 29 | }, 30 | }, 31 | resolve: { 32 | extensions: ['.tsx', '.ts', '.jsx', '.js', '.mdx', '.woff2'], 33 | alias: { 34 | react: 'preact/compat', 35 | 'react-dom': 'preact/compat', 36 | buble: '@philpl/buble', 37 | wouter: 'wouter-preact', 38 | }, 39 | }, 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.(jsx?|tsx?)$/, 44 | exclude: /node_modules/, 45 | use: ['babel-loader', '@linaria/webpack-loader'], 46 | }, 47 | { 48 | test: /\.mdx$/, 49 | exclude: /node_modules/, 50 | use: [ 51 | 'babel-loader', 52 | { 53 | loader: '@mdx-js/loader', 54 | options: { 55 | remarkPlugins: [remarkSlug, remarkPrism], 56 | }, 57 | }, 58 | ], 59 | }, 60 | { 61 | test: /\.(svg|png)$/, 62 | type: 'asset/resource', 63 | generator: { 64 | filename: 'images/[hash][ext][query]', 65 | }, 66 | }, 67 | { 68 | test: /\.(woff(2)?|ttf|eot)(\?.*)?$/, 69 | type: 'asset/resource', 70 | generator: { 71 | filename: 'fonts/[hash][ext][query]', 72 | }, 73 | }, 74 | { 75 | test: /\.css$/, 76 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 77 | }, 78 | ], 79 | }, 80 | plugins: [ 81 | new CleanWebpackPlugin(), 82 | new MiniCssExtractPlugin(), 83 | new CopyWebpackPlugin({ 84 | patterns: ['./src/robots.txt'], 85 | }), 86 | // new BundleAnalyzerPlugin(), 87 | new HTMLWebpackPlugin({ 88 | inject: true, 89 | template: path.resolve(__dirname, './src/index.html'), 90 | }), 91 | new FaviconsWebpackPlugin({ 92 | mode: 'webapp', 93 | cache: true, 94 | logo: path.resolve(__dirname, './src/assets/icon.svg'), 95 | inject: true, 96 | favicons: { 97 | // Manifest file 98 | appName: 'Fielder Docs', 99 | appDescription: 'Fielder docs', 100 | start_url: '/', 101 | display: 'standalone', 102 | background: '#fff', 103 | theme_color: '#000', 104 | orientation: 'portrait', 105 | // Icons 106 | icons: { 107 | coast: false, 108 | yandex: false, 109 | windows: false, 110 | }, 111 | }, 112 | outputPath: './manifest', 113 | prefix: '/manifest/', 114 | }), 115 | new InjectManifest({ 116 | swSrc: path.resolve(__dirname, './src/service-worker.tsx'), 117 | exclude: [/manifest\//, /fonts\//], 118 | }), 119 | ], 120 | devServer: { 121 | historyApiFallback: true, 122 | }, 123 | }; 124 | -------------------------------------------------------------------------------- /examples/1-basics/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /examples/1-basics/.nvmrc: -------------------------------------------------------------------------------- 1 | 15 -------------------------------------------------------------------------------- /examples/1-basics/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:1234", 3 | "fixturesFolder": false, 4 | "pluginsFile": false, 5 | "supportFile": false 6 | } 7 | -------------------------------------------------------------------------------- /examples/1-basics/cypress/integration/index.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/'); 3 | }); 4 | 5 | describe('username', () => { 6 | it('changes value', () => { 7 | const value = 'hi'; 8 | cy.get('input[name="username"]').type(value).should('have.value', value); 9 | }); 10 | 11 | it('validates on blur', () => { 12 | cy.get('input[name="username"]') 13 | .type('hi') 14 | .blur() 15 | .next() 16 | .should('contain.text', 'Username must be at least 4 characters.'); 17 | }); 18 | 19 | it('updates validation on change', () => { 20 | cy.get('input[name="username"]') 21 | .type('hi') 22 | .blur() 23 | .type('hello there') 24 | .next() 25 | .should('not.exist'); 26 | }); 27 | }); 28 | 29 | describe('password', () => { 30 | it('changes value', () => { 31 | const value = 'hi'; 32 | 33 | cy.get('input[name="password"]').type(value).should('have.value', value); 34 | }); 35 | 36 | it('validates on blur', () => { 37 | cy.get('input[name="password"]') 38 | .type('hi') 39 | .blur() 40 | .next() 41 | .should('contain.text', 'Password must be at least 4 characters.'); 42 | }); 43 | 44 | it('updates validation on change', () => { 45 | cy.get('input[name="password"]') 46 | .type('hi') 47 | .blur() 48 | .type('hello there') 49 | .next() 50 | .should('not.exist'); 51 | }); 52 | }); 53 | 54 | describe('next button', () => { 55 | it('is disabled on mount', () => { 56 | cy.get('button').should('have.attr', 'disabled'); 57 | }); 58 | 59 | it('is enabled when fields are valid', () => { 60 | cy.get('input[name="username"]').type('hello there'); 61 | cy.get('input[name="password"]').type('hello there'); 62 | cy.get('button').should('not.have.attr', 'disabled'); 63 | }); 64 | 65 | it('is disabled when fields are invalid', () => { 66 | cy.get('input[name="username"]').type('hello there'); 67 | cy.get('input[name="password"]').type('hello there'); 68 | cy.get('input[name="password"]').clear().type('hi'); 69 | cy.get('button').should('have.attr', 'disabled'); 70 | }); 71 | }); 72 | 73 | describe('submit validation', () => { 74 | it('shows loading state', () => { 75 | cy.get('input[name="username"]').type('hello there'); 76 | cy.get('input[name="password"]').type('hello there'); 77 | cy.get('button').click(); 78 | cy.get('button').should('have.text', '...'); 79 | }); 80 | 81 | it('shows alert on completion', () => { 82 | let alerts = 0; 83 | cy.on('window:alert', () => { 84 | alerts += 1; 85 | }); 86 | 87 | cy.get('input[name="username"]').type('hello there'); 88 | cy.get('input[name="password"]').type('hello there'); 89 | cy.get('button').click(); 90 | 91 | cy.log('Checking for button to return from fetching state'); 92 | cy.get('button') 93 | .should('have.text', 'Next') 94 | .then(() => expect(alerts).to.eq(1)); 95 | 96 | cy.log('Checking for no validation errors'); 97 | cy.get('input[name="username"]').next().should('not.exist'); 98 | }); 99 | 100 | it('shows error on fail', () => { 101 | cy.get('input[name="username"]').type('taken'); 102 | cy.get('input[name="password"]').type('hello there'); 103 | cy.get('button').click(); 104 | 105 | cy.log('Checking for button to return from fetching state'); 106 | cy.get('button').should('have.text', 'Next'); 107 | 108 | cy.log('Checking for no validation errors'); 109 | cy.get('input[name="username"]') 110 | .next() 111 | .should('contain.text', 'Username is already taken'); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /examples/1-basics/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/1-basics/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Parcel Sandbox 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/1-basics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fielder-example-1", 3 | "version": "0.0.0", 4 | "description": "Example fielder project", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "parcel index.html --open", 8 | "serve": "parcel serve index.html", 9 | "test": "cypress run", 10 | "build": "parcel build index.html", 11 | "setup-dev": "node setup.mjs" 12 | }, 13 | "dependencies": { 14 | "fielder": "latest", 15 | "react": "16.13.1", 16 | "react-dom": "16.13.0", 17 | "semantic-ui-css": "2.4.1", 18 | "semantic-ui-react": "0.88.2" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^16.9.19", 22 | "@types/react-dom": "^16.9.5", 23 | "cypress": "^6.1.0", 24 | "parcel-bundler": "^1.6.1", 25 | "typescript": "^3.7.5" 26 | }, 27 | "browserslist": [ 28 | "last 1 Chrome version" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /examples/1-basics/setup.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const updatePackage = () => { 4 | const json = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); 5 | const newJson = { 6 | ...json, 7 | alias: { 8 | react: '../../node_modules/react', 9 | 'react-dom': '../../node_modules/react-dom', 10 | fielder: '../../', 11 | }, 12 | }; 13 | 14 | fs.writeFileSync('./package.json', JSON.stringify(newJson, null, 2), { 15 | encoding: 'utf-8', 16 | }); 17 | }; 18 | 19 | const updateTsconfig = () => { 20 | const json = JSON.parse(fs.readFileSync('./tsconfig.json', 'utf-8')); 21 | const newJson = { 22 | ...json, 23 | compilerOptions: { 24 | ...json.compilerOptions, 25 | paths: { 26 | fielder: ['../../'], 27 | }, 28 | }, 29 | }; 30 | 31 | fs.writeFileSync('./tsconfig.json', JSON.stringify(newJson, null, 2), { 32 | encoding: 'utf-8', 33 | }); 34 | }; 35 | 36 | updatePackage(); 37 | updateTsconfig(); 38 | -------------------------------------------------------------------------------- /examples/1-basics/src/components/Card.css: -------------------------------------------------------------------------------- 1 | .card { 2 | margin: 10px !important; 3 | } 4 | 5 | .disabled-section { 6 | max-height: 30px; 7 | overflow: hidden; 8 | } 9 | -------------------------------------------------------------------------------- /examples/1-basics/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import './Card.css'; 3 | 4 | export const Card: FC = ({ children }) => ( 5 |
6 | {children} 7 |
8 | ); 9 | 10 | export const CardSection: FC<{ disabled?: boolean }> = ({ 11 | children, 12 | disabled, 13 | }) => ( 14 |
15 | {children} 16 |
17 | ); 18 | -------------------------------------------------------------------------------- /examples/1-basics/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Card'; 2 | -------------------------------------------------------------------------------- /examples/1-basics/src/form/Form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FielderProvider, useForm } from 'fielder'; 3 | import { Card, CardSection } from '../components/Card'; 4 | import { FormContent } from './FormContent'; 5 | 6 | export const Form = () => { 7 | const state = useForm(); 8 | 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /examples/1-basics/src/form/FormContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback } from 'react'; 2 | import { useField, useFormContext, useSubmit, ValidationFn } from 'fielder'; 3 | import { conditionalError } from '../util'; 4 | 5 | type FormSchema = { 6 | username: string; 7 | password: string; 8 | }; 9 | 10 | export const FormContent: FC = () => { 11 | const { isValid } = useFormContext(); 12 | const [usernameProps, usernameMeta] = useField({ 13 | name: 'username', 14 | initialValue: '', 15 | validate: usernameValidation, 16 | }); 17 | const [passwordProps, passwordMeta] = useField({ 18 | name: 'password', 19 | initialValue: '', 20 | validate: passwordValidation, 21 | }); 22 | 23 | const { handleSubmit, isValidating } = useSubmit( 24 | useCallback(() => { 25 | alert('Submitted!'); 26 | }, []) 27 | ); 28 | 29 | return ( 30 |
31 |
32 | 33 | 34 | {conditionalError(usernameMeta)} 35 |
36 |
37 | 38 | 39 | {conditionalError(passwordMeta)} 40 |
41 |
42 | 50 |
51 |
52 | ); 53 | }; 54 | 55 | const usernameValidation: ValidationFn = ({ value, trigger }) => { 56 | if (!value) { 57 | throw Error('Username is required.'); 58 | } 59 | 60 | if (value.length < 4) { 61 | throw Error('Username must be at least 4 characters.'); 62 | } 63 | 64 | if (trigger === 'submit') { 65 | return isUsernameTaken(value).then((isTaken) => { 66 | console.log({ isTaken }); 67 | if (isTaken) { 68 | throw Error('Username is already taken'); 69 | } 70 | }); 71 | } 72 | }; 73 | 74 | const passwordValidation: ValidationFn = ({ value }) => { 75 | if (!value) { 76 | throw Error('Password is required.'); 77 | } 78 | 79 | if (value.length < 4) { 80 | throw Error('Password must be at least 4 characters.'); 81 | } 82 | }; 83 | 84 | const isUsernameTaken = (username: string) => 85 | new Promise((resolve, reject) => { 86 | const taken = username === 'taken'; 87 | 88 | setTimeout(() => resolve(taken), 1000); 89 | }); 90 | -------------------------------------------------------------------------------- /examples/1-basics/src/form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Form'; 2 | -------------------------------------------------------------------------------- /examples/1-basics/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Form } from './form'; 4 | 5 | const App = () => ( 6 |
7 |
8 |
9 | ); 10 | 11 | ReactDOM.render(, document.querySelector('#root')); 12 | -------------------------------------------------------------------------------- /examples/1-basics/src/util.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { UseFieldMeta } from 'fielder'; 3 | import { Label } from 'semantic-ui-react'; 4 | 5 | export const conditionalError = (meta: UseFieldMeta) => 6 | meta.hasBlurred && 7 | meta.error && ( 8 | 11 | ); 12 | -------------------------------------------------------------------------------- /examples/1-basics/styles/site.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.4.1 - Site 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Released under the MIT license 7 | * http://opensource.org/licenses/MIT 8 | * 9 | */ 10 | 11 | /******************************* 12 | Page 13 | *******************************/ 14 | 15 | @import url('https://fonts.googleapis.com/css?family=Lato:400,700,400italic,700italic&subset=latin'); 16 | html, 17 | body { 18 | height: 100%; 19 | } 20 | html { 21 | font-size: 14px; 22 | } 23 | body { 24 | margin: 0px; 25 | padding: 0px; 26 | overflow-x: hidden; 27 | min-width: 320px; 28 | background: #ffffff; 29 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 30 | font-size: 14px; 31 | line-height: 1.4285em; 32 | color: rgba(0, 0, 0, 0.87); 33 | font-smoothing: antialiased; 34 | } 35 | 36 | /******************************* 37 | Headers 38 | *******************************/ 39 | 40 | h1, 41 | h2, 42 | h3, 43 | h4, 44 | h5 { 45 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 46 | line-height: 1.28571429em; 47 | margin: calc(2rem - 0.14285714em) 0em 1rem; 48 | font-weight: bold; 49 | padding: 0em; 50 | } 51 | h1 { 52 | min-height: 1rem; 53 | font-size: 2rem; 54 | } 55 | h2 { 56 | font-size: 1.71428571rem; 57 | } 58 | h3 { 59 | font-size: 1.28571429rem; 60 | } 61 | h4 { 62 | font-size: 1.07142857rem; 63 | } 64 | h5 { 65 | font-size: 1rem; 66 | } 67 | h1:first-child, 68 | h2:first-child, 69 | h3:first-child, 70 | h4:first-child, 71 | h5:first-child { 72 | margin-top: 0em; 73 | } 74 | h1:last-child, 75 | h2:last-child, 76 | h3:last-child, 77 | h4:last-child, 78 | h5:last-child { 79 | margin-bottom: 0em; 80 | } 81 | 82 | /******************************* 83 | Text 84 | *******************************/ 85 | 86 | p { 87 | margin: 0em 0em 1em; 88 | line-height: 1.4285em; 89 | } 90 | p:first-child { 91 | margin-top: 0em; 92 | } 93 | p:last-child { 94 | margin-bottom: 0em; 95 | } 96 | 97 | /*------------------- 98 | Links 99 | --------------------*/ 100 | 101 | a { 102 | color: #4183c4; 103 | text-decoration: none; 104 | } 105 | a:hover { 106 | color: #1e70bf; 107 | text-decoration: none; 108 | } 109 | 110 | /******************************* 111 | Scrollbars 112 | *******************************/ 113 | 114 | /******************************* 115 | Highlighting 116 | *******************************/ 117 | 118 | /* Site */ 119 | ::-webkit-selection { 120 | background-color: #cce2ff; 121 | color: rgba(0, 0, 0, 0.87); 122 | } 123 | ::-moz-selection { 124 | background-color: #cce2ff; 125 | color: rgba(0, 0, 0, 0.87); 126 | } 127 | ::selection { 128 | background-color: #cce2ff; 129 | color: rgba(0, 0, 0, 0.87); 130 | } 131 | 132 | /* Form */ 133 | textarea::-webkit-selection, 134 | input::-webkit-selection { 135 | background-color: rgba(100, 100, 100, 0.4); 136 | color: rgba(0, 0, 0, 0.87); 137 | } 138 | textarea::-moz-selection, 139 | input::-moz-selection { 140 | background-color: rgba(100, 100, 100, 0.4); 141 | color: rgba(0, 0, 0, 0.87); 142 | } 143 | textarea::selection, 144 | input::selection { 145 | background-color: rgba(100, 100, 100, 0.4); 146 | color: rgba(0, 0, 0, 0.87); 147 | } 148 | 149 | /* Force Simple Scrollbars */ 150 | body ::-webkit-scrollbar { 151 | -webkit-appearance: none; 152 | width: 10px; 153 | height: 10px; 154 | } 155 | body ::-webkit-scrollbar-track { 156 | background: rgba(0, 0, 0, 0.1); 157 | border-radius: 0px; 158 | } 159 | body ::-webkit-scrollbar-thumb { 160 | cursor: pointer; 161 | border-radius: 5px; 162 | background: rgba(0, 0, 0, 0.25); 163 | -webkit-transition: color 0.2s ease; 164 | transition: color 0.2s ease; 165 | } 166 | body ::-webkit-scrollbar-thumb:window-inactive { 167 | background: rgba(0, 0, 0, 0.15); 168 | } 169 | body ::-webkit-scrollbar-thumb:hover { 170 | background: rgba(128, 135, 139, 0.8); 171 | } 172 | 173 | /* Inverted UI */ 174 | body .ui.inverted::-webkit-scrollbar-track { 175 | background: rgba(255, 255, 255, 0.1); 176 | } 177 | body .ui.inverted::-webkit-scrollbar-thumb { 178 | background: rgba(255, 255, 255, 0.25); 179 | } 180 | body .ui.inverted::-webkit-scrollbar-thumb:window-inactive { 181 | background: rgba(255, 255, 255, 0.15); 182 | } 183 | body .ui.inverted::-webkit-scrollbar-thumb:hover { 184 | background: rgba(255, 255, 255, 0.35); 185 | } 186 | 187 | /******************************* 188 | Global Overrides 189 | *******************************/ 190 | 191 | /******************************* 192 | Site Overrides 193 | *******************************/ 194 | -------------------------------------------------------------------------------- /examples/1-basics/styles/style.css: -------------------------------------------------------------------------------- 1 | @import url('./button.css'); 2 | @import url('./card.css'); 3 | @import url('./form.css'); 4 | @import url('./label.css'); 5 | @import url('./site.css'); 6 | @import url('./step.css'); 7 | 8 | body, 9 | html { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | #root { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | height: 100%; 19 | background-color: #232323; 20 | } 21 | 22 | .card { 23 | border-radius: 1px !important; 24 | } 25 | -------------------------------------------------------------------------------- /examples/1-basics/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "jsx": "react", 5 | "esModuleInterop": true, 6 | "sourceMap": true, 7 | "allowJs": true, 8 | "lib": ["es6", "dom"], 9 | "rootDir": "src", 10 | "moduleResolution": "node", 11 | "baseUrl": "." 12 | }, 13 | "exclude": ["cypress"] 14 | } 15 | -------------------------------------------------------------------------------- /examples/2-multi-step/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /examples/2-multi-step/.nvmrc: -------------------------------------------------------------------------------- 1 | 15 -------------------------------------------------------------------------------- /examples/2-multi-step/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:1234", 3 | "fixturesFolder": false, 4 | "pluginsFile": false, 5 | "supportFile": false 6 | } 7 | -------------------------------------------------------------------------------- /examples/2-multi-step/cypress/integration/index.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/'); 3 | }); 4 | 5 | describe('auth info', () => { 6 | describe('username', () => { 7 | it('changes value', () => { 8 | const value = 'hi'; 9 | 10 | cy.get('input[name="username"]').type(value).should('have.value', value); 11 | }); 12 | 13 | it('validates on blur', () => { 14 | cy.get('input[name="username"]') 15 | .type('hi') 16 | .blur() 17 | .next() 18 | .should('contain.text', 'Username must be at least 4 characters.'); 19 | }); 20 | 21 | it('updates validation on change', () => { 22 | cy.get('input[name="username"]') 23 | .type('hi') 24 | .blur() 25 | .type('hello there') 26 | .next() 27 | .should('not.exist'); 28 | }); 29 | }); 30 | 31 | describe('password', () => { 32 | it('changes value', () => { 33 | const value = 'hi'; 34 | cy.get('input[name="password"]').type(value).should('have.value', value); 35 | }); 36 | 37 | it('validates on blur', () => { 38 | cy.get('input[name="password"]') 39 | .type('hi') 40 | .blur() 41 | .next() 42 | .should('contain.text', 'Password must be at least 4 characters.'); 43 | }); 44 | 45 | it('updates validation on change', () => { 46 | cy.get('input[name="password"]') 47 | .type('hi') 48 | .blur() 49 | .type('hello there') 50 | .next() 51 | .should('not.exist'); 52 | }); 53 | }); 54 | 55 | describe('password confirmation', () => { 56 | it('changes value', () => { 57 | const value = 'hi'; 58 | 59 | cy.get('input[name="passwordConfirmation"]') 60 | .type(value) 61 | .should('have.value', value); 62 | }); 63 | 64 | it('validates (cross form) on blur', () => { 65 | cy.get('input[name="password"]').type('validpassword'); 66 | 67 | cy.get('input[name="passwordConfirmation"]') 68 | .type('hi') 69 | .blur() 70 | .next() 71 | .should('contain.text', 'Password does not match'); 72 | }); 73 | 74 | it('updates validation (cross form) on change', () => { 75 | const value = 'validpassword'; 76 | cy.get('input[name="password"]').type(value).blur(); 77 | 78 | cy.get('input[name="passwordConfirmation"]') 79 | .type('hi') 80 | .blur() 81 | .clear() 82 | .type(value) 83 | .next() 84 | .should('not.exist'); 85 | }); 86 | 87 | it('updates validation (cross form) on form-wide change', () => { 88 | const value = 'validpassword'; 89 | 90 | cy.get('input[name="password"]').type('somegibberish').blur(); 91 | 92 | cy.get('input[name="passwordConfirmation"]').type(value).blur(); 93 | 94 | cy.get('input[name="password"]').clear().type(value); 95 | 96 | cy.get('input[name="passwordConfirmation"]').next().should('not.exist'); 97 | }); 98 | }); 99 | 100 | describe('next button', () => { 101 | it('is disabled on mount', () => { 102 | cy.get('button').should('have.attr', 'disabled'); 103 | }); 104 | 105 | it('is enabled when fields are valid', () => { 106 | cy.get('input[name="username"]').type('hello there'); 107 | cy.get('input[name="password"]').type('hello there'); 108 | cy.get('input[name="passwordConfirmation"]').type('hello there'); 109 | cy.get('button').should('not.have.attr', 'disabled'); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('terms section', () => { 115 | beforeEach(() => { 116 | cy.get('input[name="username"]').type('hello there'); 117 | cy.get('input[name="password"]').type('hello there'); 118 | cy.get('input[name="passwordConfirmation"]').type('hello there'); 119 | cy.get('button').click(); 120 | }); 121 | 122 | describe('marketing checkbox', () => { 123 | it('is checked', () => { 124 | cy.get('input[value="marketing"]').should('be.checked'); 125 | }); 126 | 127 | it('toggles on click', () => { 128 | cy.get('input[value="marketing"]').click().should('not.be.checked'); 129 | }); 130 | }); 131 | 132 | describe('submit button', () => { 133 | it('is disabled on mount', () => { 134 | cy.get('button').should('have.attr', 'disabled'); 135 | }); 136 | 137 | it('is enabled when legal terms are checked', () => { 138 | cy.get('input[value="legal"]').click(); 139 | cy.get('button').should('not.have.attr', 'disabled'); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /examples/2-multi-step/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/2-multi-step/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Parcel Sandbox 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/2-multi-step/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fielder-example-2", 3 | "version": "0.0.0", 4 | "description": "Example fielder project", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "parcel index.html --open", 8 | "serve": "parcel serve index.html", 9 | "test": "cypress run", 10 | "build": "parcel build index.html", 11 | "setup-dev": "node setup.mjs" 12 | }, 13 | "dependencies": { 14 | "fielder": "latest", 15 | "react": "16.13.1", 16 | "react-dom": "16.13.0", 17 | "semantic-ui-css": "2.4.1", 18 | "semantic-ui-react": "0.88.2" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^16.9.19", 22 | "@types/react-dom": "^16.9.5", 23 | "cypress": "^6.1.0", 24 | "parcel-bundler": "^1.12.4", 25 | "typescript": "^3.7.5" 26 | }, 27 | "browserslist": [ 28 | "last 1 Chrome version" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /examples/2-multi-step/setup.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const updatePackage = () => { 4 | const json = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); 5 | const newJson = { 6 | ...json, 7 | alias: { 8 | react: '../../node_modules/react', 9 | 'react-dom': '../../node_modules/react-dom', 10 | fielder: '../../', 11 | }, 12 | }; 13 | 14 | fs.writeFileSync('./package.json', JSON.stringify(newJson, null, 2), { 15 | encoding: 'utf-8', 16 | }); 17 | }; 18 | 19 | const updateTsconfig = () => { 20 | const json = JSON.parse(fs.readFileSync('./tsconfig.json', 'utf-8')); 21 | const newJson = { 22 | ...json, 23 | compilerOptions: { 24 | ...json.compilerOptions, 25 | paths: { 26 | fielder: ['../../'], 27 | }, 28 | }, 29 | }; 30 | 31 | fs.writeFileSync('./tsconfig.json', JSON.stringify(newJson, null, 2), { 32 | encoding: 'utf-8', 33 | }); 34 | }; 35 | 36 | updatePackage(); 37 | updateTsconfig(); 38 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/components/Card.css: -------------------------------------------------------------------------------- 1 | .card { 2 | margin: 10px !important; 3 | } 4 | 5 | .disabled-section { 6 | max-height: 30px; 7 | overflow: hidden; 8 | } 9 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import './Card.css'; 3 | 4 | export const Card: FC = ({ children }) => ( 5 |
6 | {children} 7 |
8 | ); 9 | 10 | export const CardSection: FC<{ disabled?: boolean }> = ({ 11 | children, 12 | disabled, 13 | }) => ( 14 |
15 | {children} 16 |
17 | ); 18 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/components/FormSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | export const FormSection: FC<{ disabled: boolean }> = ({ 4 | disabled, 5 | children, 6 | }) =>
{children}
; 7 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FormSection'; 2 | export * from './Card'; 3 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { RegisterForm } from './register-form'; 4 | 5 | const App = () => ( 6 |
7 | 8 |
9 | ); 10 | 11 | ReactDOM.render(, document.querySelector('#root')); 12 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/register-form/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import { FielderProvider, useForm } from 'fielder'; 3 | import { CredentialsSection, TermsSection } from './sections'; 4 | import { Step } from 'semantic-ui-react'; 5 | import { Card, CardSection } from '../components/Card'; 6 | 7 | export const RegisterForm = () => { 8 | const state = useForm(); 9 | console.log(state); 10 | const [activeStep, setActiveStep] = useState(0); 11 | 12 | const handleCredentials = useCallback(() => { 13 | setActiveStep(1); 14 | }, []); 15 | 16 | return ( 17 | 18 | 19 | 20 |

Authentication info

21 | {activeStep === 0 && ( 22 | 23 | )} 24 |
25 | 26 |

Terms

27 | {activeStep === 1 && } 28 |
29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/register-form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Form'; 2 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/register-form/sections/Credentials.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useField, useFormContext } from 'fielder'; 3 | import { conditionalError } from '../../util'; 4 | 5 | export const CredentialsSection: FC<{ onComplete: () => void }> = ({ 6 | onComplete, 7 | }) => { 8 | const { isValid } = useFormContext(); 9 | const [usernameProps, usernameMeta] = useField({ 10 | name: 'username', 11 | validate: usernameValidation, 12 | initialValue: '', 13 | }); 14 | const [passwordProps, passwordMeta] = useField({ 15 | name: 'password', 16 | validate: passwordValidation, 17 | initialValue: '', 18 | }); 19 | const [passwordConfProps, passwordConfMeta] = useField({ 20 | name: 'passwordConfirmation', 21 | validate: passwordConfValidation, 22 | initialValue: '', 23 | destroyOnUnmount: true, 24 | }); 25 | 26 | return ( 27 | 28 |
29 | 30 | 31 | {conditionalError(usernameMeta)} 32 |
33 |
34 | 35 | 36 | {conditionalError(passwordMeta)} 37 |
38 |
39 | 40 | 41 | {conditionalError(passwordConfMeta)} 42 |
43 |
44 | 47 |
48 | 49 | ); 50 | }; 51 | 52 | const usernameValidation = ({ value }) => { 53 | if (!value) { 54 | throw Error('Username is required.'); 55 | } 56 | 57 | if (value.length < 4) { 58 | throw Error('Username must be at least 4 characters.'); 59 | } 60 | }; 61 | 62 | const passwordValidation = ({ value }) => { 63 | if (!value) { 64 | throw Error('Password is required.'); 65 | } 66 | 67 | if (value.length < 4) { 68 | throw Error('Password must be at least 4 characters.'); 69 | } 70 | }; 71 | 72 | const passwordConfValidation = ({ value, form }) => { 73 | if (!value) { 74 | throw Error('Password confirmation is required.'); 75 | } 76 | 77 | if (value !== form.password.value) { 78 | throw Error('Password does not match.'); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/register-form/sections/Terms.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, useMemo } from 'react'; 2 | import { useField, useFormContext } from 'fielder'; 3 | 4 | export const TermsSection: FC = () => { 5 | const { isValid } = useFormContext(); 6 | const [termsProps] = useField({ 7 | name: 'terms', 8 | validate: termsValidation, 9 | initialValue: ['marketing'], 10 | }); 11 | 12 | const checkboxes = useMemo( 13 | () => [ 14 | { label: 'Send me marketing mail', value: 'marketing' }, 15 | { label: 'I accept terms and conditions', value: 'legal' }, 16 | ], 17 | [] 18 | ); 19 | 20 | const handleSubmit = useCallback(() => alert('Form submitted'), []); 21 | 22 | return ( 23 |
24 | {checkboxes.map(({ label, value }) => ( 25 |
26 | 32 | {label} 33 |
34 | ))} 35 |
36 | 39 |
40 |
41 | ); 42 | }; 43 | 44 | const termsValidation = ({ value }) => { 45 | if (!value.includes('legal')) { 46 | throw Error('Legal terms must be accepted'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/register-form/sections/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Credentials'; 2 | export * from './Terms'; 3 | -------------------------------------------------------------------------------- /examples/2-multi-step/src/util.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { UseFieldMeta } from 'fielder'; 3 | import { Label } from 'semantic-ui-react'; 4 | 5 | export const conditionalError = (meta: UseFieldMeta) => 6 | meta.hasBlurred && 7 | meta.error && ( 8 | 11 | ); 12 | -------------------------------------------------------------------------------- /examples/2-multi-step/styles/site.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.4.1 - Site 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Released under the MIT license 7 | * http://opensource.org/licenses/MIT 8 | * 9 | */ 10 | 11 | /******************************* 12 | Page 13 | *******************************/ 14 | 15 | @import url('https://fonts.googleapis.com/css?family=Lato:400,700,400italic,700italic&subset=latin'); 16 | html, 17 | body { 18 | height: 100%; 19 | } 20 | html { 21 | font-size: 14px; 22 | } 23 | body { 24 | margin: 0px; 25 | padding: 0px; 26 | overflow-x: hidden; 27 | min-width: 320px; 28 | background: #ffffff; 29 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 30 | font-size: 14px; 31 | line-height: 1.4285em; 32 | color: rgba(0, 0, 0, 0.87); 33 | font-smoothing: antialiased; 34 | } 35 | 36 | /******************************* 37 | Headers 38 | *******************************/ 39 | 40 | h1, 41 | h2, 42 | h3, 43 | h4, 44 | h5 { 45 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 46 | line-height: 1.28571429em; 47 | margin: calc(2rem - 0.14285714em) 0em 1rem; 48 | font-weight: bold; 49 | padding: 0em; 50 | } 51 | h1 { 52 | min-height: 1rem; 53 | font-size: 2rem; 54 | } 55 | h2 { 56 | font-size: 1.71428571rem; 57 | } 58 | h3 { 59 | font-size: 1.28571429rem; 60 | } 61 | h4 { 62 | font-size: 1.07142857rem; 63 | } 64 | h5 { 65 | font-size: 1rem; 66 | } 67 | h1:first-child, 68 | h2:first-child, 69 | h3:first-child, 70 | h4:first-child, 71 | h5:first-child { 72 | margin-top: 0em; 73 | } 74 | h1:last-child, 75 | h2:last-child, 76 | h3:last-child, 77 | h4:last-child, 78 | h5:last-child { 79 | margin-bottom: 0em; 80 | } 81 | 82 | /******************************* 83 | Text 84 | *******************************/ 85 | 86 | p { 87 | margin: 0em 0em 1em; 88 | line-height: 1.4285em; 89 | } 90 | p:first-child { 91 | margin-top: 0em; 92 | } 93 | p:last-child { 94 | margin-bottom: 0em; 95 | } 96 | 97 | /*------------------- 98 | Links 99 | --------------------*/ 100 | 101 | a { 102 | color: #4183c4; 103 | text-decoration: none; 104 | } 105 | a:hover { 106 | color: #1e70bf; 107 | text-decoration: none; 108 | } 109 | 110 | /******************************* 111 | Scrollbars 112 | *******************************/ 113 | 114 | /******************************* 115 | Highlighting 116 | *******************************/ 117 | 118 | /* Site */ 119 | ::-webkit-selection { 120 | background-color: #cce2ff; 121 | color: rgba(0, 0, 0, 0.87); 122 | } 123 | ::-moz-selection { 124 | background-color: #cce2ff; 125 | color: rgba(0, 0, 0, 0.87); 126 | } 127 | ::selection { 128 | background-color: #cce2ff; 129 | color: rgba(0, 0, 0, 0.87); 130 | } 131 | 132 | /* Form */ 133 | textarea::-webkit-selection, 134 | input::-webkit-selection { 135 | background-color: rgba(100, 100, 100, 0.4); 136 | color: rgba(0, 0, 0, 0.87); 137 | } 138 | textarea::-moz-selection, 139 | input::-moz-selection { 140 | background-color: rgba(100, 100, 100, 0.4); 141 | color: rgba(0, 0, 0, 0.87); 142 | } 143 | textarea::selection, 144 | input::selection { 145 | background-color: rgba(100, 100, 100, 0.4); 146 | color: rgba(0, 0, 0, 0.87); 147 | } 148 | 149 | /* Force Simple Scrollbars */ 150 | body ::-webkit-scrollbar { 151 | -webkit-appearance: none; 152 | width: 10px; 153 | height: 10px; 154 | } 155 | body ::-webkit-scrollbar-track { 156 | background: rgba(0, 0, 0, 0.1); 157 | border-radius: 0px; 158 | } 159 | body ::-webkit-scrollbar-thumb { 160 | cursor: pointer; 161 | border-radius: 5px; 162 | background: rgba(0, 0, 0, 0.25); 163 | -webkit-transition: color 0.2s ease; 164 | transition: color 0.2s ease; 165 | } 166 | body ::-webkit-scrollbar-thumb:window-inactive { 167 | background: rgba(0, 0, 0, 0.15); 168 | } 169 | body ::-webkit-scrollbar-thumb:hover { 170 | background: rgba(128, 135, 139, 0.8); 171 | } 172 | 173 | /* Inverted UI */ 174 | body .ui.inverted::-webkit-scrollbar-track { 175 | background: rgba(255, 255, 255, 0.1); 176 | } 177 | body .ui.inverted::-webkit-scrollbar-thumb { 178 | background: rgba(255, 255, 255, 0.25); 179 | } 180 | body .ui.inverted::-webkit-scrollbar-thumb:window-inactive { 181 | background: rgba(255, 255, 255, 0.15); 182 | } 183 | body .ui.inverted::-webkit-scrollbar-thumb:hover { 184 | background: rgba(255, 255, 255, 0.35); 185 | } 186 | 187 | /******************************* 188 | Global Overrides 189 | *******************************/ 190 | 191 | /******************************* 192 | Site Overrides 193 | *******************************/ 194 | -------------------------------------------------------------------------------- /examples/2-multi-step/styles/style.css: -------------------------------------------------------------------------------- 1 | @import url('./button.css'); 2 | @import url('./card.css'); 3 | @import url('./form.css'); 4 | @import url('./label.css'); 5 | @import url('./site.css'); 6 | @import url('./step.css'); 7 | 8 | body, 9 | html { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | #root { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | height: 100%; 19 | background-color: #232323; 20 | } 21 | 22 | .card { 23 | border-radius: 1px !important; 24 | } 25 | -------------------------------------------------------------------------------- /examples/2-multi-step/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "jsx": "react", 5 | "esModuleInterop": true, 6 | "sourceMap": true, 7 | "allowJs": true, 8 | "lib": ["es6", "dom"], 9 | "rootDir": "src", 10 | "moduleResolution": "node", 11 | "baseUrl": "." 12 | }, 13 | "exclude": ["cypress"] 14 | } 15 | -------------------------------------------------------------------------------- /examples/3-branching/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /examples/3-branching/.nvmrc: -------------------------------------------------------------------------------- 1 | 15 -------------------------------------------------------------------------------- /examples/3-branching/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:1234", 3 | "fixturesFolder": false, 4 | "pluginsFile": false, 5 | "supportFile": false 6 | } 7 | -------------------------------------------------------------------------------- /examples/3-branching/cypress/integration/index.ts: -------------------------------------------------------------------------------- 1 | beforeEach(() => { 2 | cy.visit('/'); 3 | }); 4 | 5 | describe('email', () => { 6 | it('changes value', () => { 7 | const value = 'hi'; 8 | 9 | cy.get('input[name="email"]').type(value).should('have.value', value); 10 | }); 11 | 12 | it('validates on blur', () => { 13 | cy.get('input[name="email"]') 14 | .focus() 15 | .blur() 16 | .next() 17 | .should('contain.text', 'Email is required'); 18 | }); 19 | 20 | it('progression is disabled', () => { 21 | cy.get('button').should('have.attr', 'disabled'); 22 | }); 23 | }); 24 | 25 | describe('login', () => { 26 | const email = 'user@mail.com'; 27 | 28 | beforeEach(() => { 29 | cy.get('input[name="email"]').type(email); 30 | cy.get('button').click(); 31 | }); 32 | 33 | it('has email', () => { 34 | cy.get('input[name="email"]').should('have.value', email); 35 | }); 36 | 37 | it('logs in', () => { 38 | const stub = cy.stub(); 39 | cy.on('window:alert', stub); 40 | 41 | cy.get('input[name="password"]').type('password'); 42 | cy.get('button') 43 | .click() 44 | .then(() => { 45 | expect(stub).to.be.called; 46 | }); 47 | }); 48 | }); 49 | 50 | describe('register', () => { 51 | const email = 'someone@mail.com'; 52 | 53 | beforeEach(() => { 54 | cy.get('input[name="email"]').type(email); 55 | cy.get('button').click(); 56 | }); 57 | 58 | it('has email', () => { 59 | cy.get('input[name="email"]').should('have.value', email); 60 | }); 61 | 62 | it('registers', () => { 63 | const stub = cy.stub(); 64 | cy.on('window:alert', stub); 65 | 66 | cy.get('input[name="name"]').type('Carl'); 67 | cy.get('input[name="password"]').type('password'); 68 | cy.get('button') 69 | .click() 70 | .then(() => { 71 | expect(stub).to.be.called; 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /examples/3-branching/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/3-branching/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Parcel Sandbox 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | ; 14 | -------------------------------------------------------------------------------- /examples/3-branching/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fielder-example-3", 3 | "version": "0.0.0", 4 | "description": "Example fielder project", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "parcel index.html --open", 8 | "serve": "parcel serve index.html", 9 | "test": "cypress run", 10 | "build": "parcel build index.html", 11 | "setup-dev": "node setup.mjs" 12 | }, 13 | "dependencies": { 14 | "fielder": "latest", 15 | "react": "16.13.0", 16 | "react-dom": "16.13.0", 17 | "semantic-ui-css": "2.4.1", 18 | "semantic-ui-react": "0.88.2" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^16.9.19", 22 | "@types/react-dom": "^16.9.5", 23 | "cypress": "^6.1.0", 24 | "parcel-bundler": "^1.6.1", 25 | "typescript": "^3.7.5" 26 | }, 27 | "browserslist": [ 28 | "last 1 Chrome version" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /examples/3-branching/setup.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const updatePackage = () => { 4 | const json = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); 5 | const newJson = { 6 | ...json, 7 | alias: { 8 | react: '../../node_modules/react', 9 | 'react-dom': '../../node_modules/react-dom', 10 | fielder: '../../', 11 | }, 12 | }; 13 | 14 | fs.writeFileSync('./package.json', JSON.stringify(newJson, null, 2), { 15 | encoding: 'utf-8', 16 | }); 17 | }; 18 | 19 | const updateTsconfig = () => { 20 | const json = JSON.parse(fs.readFileSync('./tsconfig.json', 'utf-8')); 21 | const newJson = { 22 | ...json, 23 | compilerOptions: { 24 | ...json.compilerOptions, 25 | paths: { 26 | fielder: ['../../'], 27 | }, 28 | }, 29 | }; 30 | 31 | fs.writeFileSync('./tsconfig.json', JSON.stringify(newJson, null, 2), { 32 | encoding: 'utf-8', 33 | }); 34 | }; 35 | 36 | updatePackage(); 37 | updateTsconfig(); 38 | -------------------------------------------------------------------------------- /examples/3-branching/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My App 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/3-branching/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Form } from './register-form'; 4 | 5 | render(
, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import { useForm, FielderProvider } from 'fielder'; 3 | import { SignUp } from './sections/SignUp'; 4 | import { GettingStarted } from './sections/GettingStarted'; 5 | import { Login } from './sections/Login'; 6 | 7 | export const Form = () => { 8 | const [continued, setContinued] = useState(false); 9 | const formState = useForm(); 10 | 11 | const formPage = useMemo(() => { 12 | if (!continued) { 13 | return 'init'; 14 | } 15 | 16 | if (formState.fields.email.value === 'user@mail.com') { 17 | return 'login'; 18 | } 19 | 20 | return 'signup'; 21 | }, [formState, continued]); 22 | 23 | const content = useMemo(() => { 24 | if (formPage === 'init') { 25 | return setContinued(true)} />; 26 | } 27 | 28 | if (formPage === 'login') { 29 | return ; 30 | } 31 | 32 | return ; 33 | }, [formPage]); 34 | 35 | return {content}; 36 | }; 37 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Form'; 2 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/sections/GettingStarted.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useField, useFormContext } from 'fielder'; 3 | import { validateEmail } from '../validation'; 4 | 5 | export const GettingStarted: FC<{ onNext: () => void }> = ({ onNext }) => { 6 | const { isValid } = useFormContext(); 7 | const [emailProps, emailMeta] = useField({ 8 | name: 'email', 9 | validate: validateEmail, 10 | initialValue: '', 11 | }); 12 | 13 | return ( 14 |
15 |

Get started

16 | 17 |
18 | 19 | 20 | 21 | {emailMeta.hasBlurred && emailMeta.error} 22 | 23 |
24 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | const placeholder = "Try 'user@mail.com'"; 33 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/sections/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useField, useFormContext } from 'fielder'; 3 | import { validateEmail, validatePassword } from '../validation'; 4 | 5 | export const Login = () => { 6 | const { isValid, fields } = useFormContext(); 7 | const [emailProps, emailMeta] = useField({ 8 | name: 'email', 9 | validate: validateEmail, 10 | initialValue: '', 11 | }); 12 | const [passwordProps, passwordMeta] = useField({ 13 | name: 'password', 14 | validate: validatePassword, 15 | initialValue: '', 16 | }); 17 | 18 | const handleSubmit = useCallback(() => { 19 | const variables = { 20 | email: fields.email.value, 21 | password: fields.password.value, 22 | }; 23 | 24 | // Simulate submission 25 | console.log('POST /register', { variables }); 26 | alert("API call 'POST /register' made. See console for more info"); 27 | }, [fields]); 28 | 29 | return ( 30 |
31 |

Log in

32 |
33 |
34 | 35 | 36 | 37 | {emailMeta.hasBlurred && emailMeta.error} 38 | 39 |
40 | 41 |
42 | 43 | 44 | 45 | {passwordMeta.hasBlurred && passwordMeta.error} 46 | 47 |
48 | 49 | 52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/sections/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback, MouseEventHandler } from 'react'; 2 | import { useField, useFormContext } from 'fielder'; 3 | import { validateEmail, validateName, validatePassword } from '../validation'; 4 | 5 | export const SignUp: FC = () => { 6 | const { isValid, fields } = useFormContext(); 7 | const [nameProps, nameMeta] = useField({ 8 | name: 'name', 9 | validate: validateName, 10 | initialValue: '', 11 | }); 12 | const [emailProps, emailMeta] = useField({ 13 | name: 'email', 14 | validate: validateEmail, 15 | initialValue: '', 16 | }); 17 | const [passwordProps, passwordMeta] = useField({ 18 | name: 'password', 19 | validate: validatePassword, 20 | initialValue: '', 21 | }); 22 | 23 | const handleSubmit = useCallback( 24 | (e) => { 25 | e.preventDefault(); 26 | const variables = { 27 | name: fields.name.value, 28 | email: fields.email.value, 29 | password: fields.password.value, 30 | }; 31 | 32 | // Simulate submission 33 | console.log('POST /register', { variables }); 34 | alert("API call 'POST /register' made. See console for more info"); 35 | }, 36 | [fields] 37 | ); 38 | 39 | return ( 40 |
41 |

Sign up

42 |
43 |
44 | 45 | 46 | 47 | {nameMeta.hasBlurred && nameMeta.error} 48 | 49 |
50 |
51 | 52 | 53 | 54 | {emailMeta.hasBlurred && emailMeta.error} 55 | 56 |
57 |
58 | 59 | 60 | 61 | {passwordMeta.hasBlurred && passwordMeta.error} 62 | 63 |
64 | 67 |
68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/sections/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GettingStarted'; 2 | export * from './Login'; 3 | export * from './SignUp'; 4 | -------------------------------------------------------------------------------- /examples/3-branching/src/register-form/validation.ts: -------------------------------------------------------------------------------- 1 | export const validateEmail = ({ value }) => { 2 | if (!value) { 3 | throw Error('Email is required.'); 4 | } 5 | 6 | const regex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/; 7 | if (!regex.test(value)) { 8 | throw Error('Enter a valid email.'); 9 | } 10 | }; 11 | 12 | export const validateName = ({ value }) => { 13 | if (!value) { 14 | throw Error('Name is required.'); 15 | } 16 | }; 17 | 18 | export const validatePassword = ({ value }) => { 19 | if (!value) { 20 | throw Error('Password is required'); 21 | } 22 | 23 | if (value.length < 4) { 24 | throw Error('Password must be at least 4 characters'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /examples/3-branching/styles/site.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * # Semantic UI 2.4.1 - Site 3 | * http://github.com/semantic-org/semantic-ui/ 4 | * 5 | * 6 | * Released under the MIT license 7 | * http://opensource.org/licenses/MIT 8 | * 9 | */ 10 | 11 | /******************************* 12 | Page 13 | *******************************/ 14 | 15 | @import url('https://fonts.googleapis.com/css?family=Lato:400,700,400italic,700italic&subset=latin'); 16 | html, 17 | body { 18 | height: 100%; 19 | } 20 | html { 21 | font-size: 14px; 22 | } 23 | body { 24 | margin: 0px; 25 | padding: 0px; 26 | overflow-x: hidden; 27 | min-width: 320px; 28 | background: #ffffff; 29 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 30 | font-size: 14px; 31 | line-height: 1.4285em; 32 | color: rgba(0, 0, 0, 0.87); 33 | font-smoothing: antialiased; 34 | } 35 | 36 | /******************************* 37 | Headers 38 | *******************************/ 39 | 40 | h1, 41 | h2, 42 | h3, 43 | h4, 44 | h5 { 45 | font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; 46 | line-height: 1.28571429em; 47 | margin: calc(2rem - 0.14285714em) 0em 1rem; 48 | font-weight: bold; 49 | padding: 0em; 50 | } 51 | h1 { 52 | min-height: 1rem; 53 | font-size: 2rem; 54 | } 55 | h2 { 56 | font-size: 1.71428571rem; 57 | } 58 | h3 { 59 | font-size: 1.28571429rem; 60 | } 61 | h4 { 62 | font-size: 1.07142857rem; 63 | } 64 | h5 { 65 | font-size: 1rem; 66 | } 67 | h1:first-child, 68 | h2:first-child, 69 | h3:first-child, 70 | h4:first-child, 71 | h5:first-child { 72 | margin-top: 0em; 73 | } 74 | h1:last-child, 75 | h2:last-child, 76 | h3:last-child, 77 | h4:last-child, 78 | h5:last-child { 79 | margin-bottom: 0em; 80 | } 81 | 82 | /******************************* 83 | Text 84 | *******************************/ 85 | 86 | p { 87 | margin: 0em 0em 1em; 88 | line-height: 1.4285em; 89 | } 90 | p:first-child { 91 | margin-top: 0em; 92 | } 93 | p:last-child { 94 | margin-bottom: 0em; 95 | } 96 | 97 | /*------------------- 98 | Links 99 | --------------------*/ 100 | 101 | a { 102 | color: #4183c4; 103 | text-decoration: none; 104 | } 105 | a:hover { 106 | color: #1e70bf; 107 | text-decoration: none; 108 | } 109 | 110 | /******************************* 111 | Scrollbars 112 | *******************************/ 113 | 114 | /******************************* 115 | Highlighting 116 | *******************************/ 117 | 118 | /* Site */ 119 | ::-webkit-selection { 120 | background-color: #cce2ff; 121 | color: rgba(0, 0, 0, 0.87); 122 | } 123 | ::-moz-selection { 124 | background-color: #cce2ff; 125 | color: rgba(0, 0, 0, 0.87); 126 | } 127 | ::selection { 128 | background-color: #cce2ff; 129 | color: rgba(0, 0, 0, 0.87); 130 | } 131 | 132 | /* Form */ 133 | textarea::-webkit-selection, 134 | input::-webkit-selection { 135 | background-color: rgba(100, 100, 100, 0.4); 136 | color: rgba(0, 0, 0, 0.87); 137 | } 138 | textarea::-moz-selection, 139 | input::-moz-selection { 140 | background-color: rgba(100, 100, 100, 0.4); 141 | color: rgba(0, 0, 0, 0.87); 142 | } 143 | textarea::selection, 144 | input::selection { 145 | background-color: rgba(100, 100, 100, 0.4); 146 | color: rgba(0, 0, 0, 0.87); 147 | } 148 | 149 | /* Force Simple Scrollbars */ 150 | body ::-webkit-scrollbar { 151 | -webkit-appearance: none; 152 | width: 10px; 153 | height: 10px; 154 | } 155 | body ::-webkit-scrollbar-track { 156 | background: rgba(0, 0, 0, 0.1); 157 | border-radius: 0px; 158 | } 159 | body ::-webkit-scrollbar-thumb { 160 | cursor: pointer; 161 | border-radius: 5px; 162 | background: rgba(0, 0, 0, 0.25); 163 | -webkit-transition: color 0.2s ease; 164 | transition: color 0.2s ease; 165 | } 166 | body ::-webkit-scrollbar-thumb:window-inactive { 167 | background: rgba(0, 0, 0, 0.15); 168 | } 169 | body ::-webkit-scrollbar-thumb:hover { 170 | background: rgba(128, 135, 139, 0.8); 171 | } 172 | 173 | /* Inverted UI */ 174 | body .ui.inverted::-webkit-scrollbar-track { 175 | background: rgba(255, 255, 255, 0.1); 176 | } 177 | body .ui.inverted::-webkit-scrollbar-thumb { 178 | background: rgba(255, 255, 255, 0.25); 179 | } 180 | body .ui.inverted::-webkit-scrollbar-thumb:window-inactive { 181 | background: rgba(255, 255, 255, 0.15); 182 | } 183 | body .ui.inverted::-webkit-scrollbar-thumb:hover { 184 | background: rgba(255, 255, 255, 0.35); 185 | } 186 | 187 | /******************************* 188 | Global Overrides 189 | *******************************/ 190 | 191 | /******************************* 192 | Site Overrides 193 | *******************************/ 194 | -------------------------------------------------------------------------------- /examples/3-branching/styles/style.css: -------------------------------------------------------------------------------- 1 | @import url('./button.css'); 2 | @import url('./card.css'); 3 | @import url('./form.css'); 4 | @import url('./label.css'); 5 | @import url('./site.css'); 6 | @import url('./step.css'); 7 | 8 | body, 9 | html { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | #root { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | height: 100%; 19 | background-color: #232323; 20 | } 21 | 22 | .card { 23 | border-radius: 1px !important; 24 | } 25 | -------------------------------------------------------------------------------- /examples/3-branching/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "jsx": "react", 5 | "esModuleInterop": true, 6 | "sourceMap": true, 7 | "allowJs": true, 8 | "lib": ["es6", "dom"], 9 | "rootDir": "src", 10 | "moduleResolution": "node", 11 | "baseUrl": "." 12 | }, 13 | "exclude": ["cypress"] 14 | } 15 | -------------------------------------------------------------------------------- /examples/4-native/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "f9155ac790fd02fadcdeca367b02581c04a353aa6d5aa84409a59f6804c87acd": true, 3 | "89ed26367cdb9b771858e026f2eb95bfdb90e5ae943e716575327ec325f39c44": true 4 | } 5 | -------------------------------------------------------------------------------- /examples/4-native/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | npm-debug.* 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | web-build/ 11 | web-report/ 12 | 13 | # macOS 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /examples/4-native/App.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback } from 'react'; 2 | import { 3 | Container, 4 | Content, 5 | Button, 6 | Text, 7 | Input, 8 | Picker, 9 | View, 10 | ListItem, 11 | CheckBox, 12 | Body, 13 | } from 'native-base'; 14 | import { useForm, FielderProvider, useField } from 'fielder'; 15 | 16 | export default function App() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | const FormRoot = () => { 27 | const formState = useForm(); 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 37 | 38 | ); 39 | }; 40 | 41 | /** 42 | * Here's an example of adapting Fielder props to work with 43 | * the Input component in NativeBase. 44 | * 45 | * 'onChange' expects a new value (or DOM event) 46 | * NativeBase's Input uses the prop 'onChangeText' for this purpose. 47 | */ 48 | const TextInput = () => { 49 | const [{ onChange: onChangeText, ...textProps }, fieldMeta] = useField({ 50 | name: 'textinput', 51 | initialValue: '', 52 | validate: useCallback((v) => { 53 | if (!v) { 54 | throw Error('Text is required'); 55 | } 56 | }, []), 57 | }); 58 | 59 | return ( 60 |
61 | Type some text 62 | 67 | {fieldMeta.hasBlurred && fieldMeta.error ? ( 68 | {fieldMeta.error} 69 | ) : null} 70 |
71 | ); 72 | }; 73 | 74 | /** 75 | * Here's an example of adapting Fielder props to work with 76 | * the Picker component in NativeBase. 77 | * 78 | * 'onChange' expects a new value (or DOM event) 79 | * NativeBase's Picker uses the prop 'onChangeValue' for this purpose. 80 | * 81 | * 'value' returns the current value of the field 82 | * NativeBase's Picker uses the prop 'selectedValue' for this purpose. 83 | * 84 | * NativeBase's Picker doesn't have an onBlur so we don't forward 85 | * that prop or use 'hasBlurred' from Fielder as it will always be false. 86 | */ 87 | const PickerInput = () => { 88 | const [{ onChange: onValueChange, value: selectedValue }] = useField({ 89 | name: 'pickerinput', 90 | initialValue: 'credit', 91 | }); 92 | 93 | const pickerValues = useMemo( 94 | () => [ 95 | { label: 'Wallet', value: 'wallet' }, 96 | { label: 'ATM Card', value: 'atm' }, 97 | { label: 'Debit Card', value: 'debit' }, 98 | { label: 'Credit Card', value: 'credit' }, 99 | ], 100 | [] 101 | ); 102 | 103 | return ( 104 |
105 | Select from the Picker 106 | 112 | {pickerValues.map((v) => ( 113 | 114 | ))} 115 | 116 |
117 | ); 118 | }; 119 | 120 | /** 121 | * Here's an example of adapting Fielder props to work with 122 | * the Checkbox component in NativeBase. 123 | * 124 | * 'onChange' expects a new value (or DOM event) 125 | * NativeBase's Checkbox uses the 'onPress' and does not provide the pressed value. 126 | */ 127 | const CheckboxInput = () => { 128 | const [{ value, onChange }, fieldMeta] = useField({ 129 | name: 'checkboxinput', 130 | initialValue: ['A', 'C'], 131 | validate: useCallback((v) => { 132 | if (v.length === 0) { 133 | throw Error('Must select at least one checkbox'); 134 | } 135 | }), 136 | initialValid: true, 137 | }); 138 | 139 | return ( 140 | <> 141 |
142 | Try selecting some checkboxes 143 | {['A', 'B', 'C', 'D'].map((v) => ( 144 | 145 | onChange(v)} /> 146 | 147 | {v} 148 | 149 | 150 | ))} 151 | {fieldMeta.error ? ( 152 | {fieldMeta.error} 153 | ) : null} 154 |
155 | 156 | ); 157 | }; 158 | 159 | const Section = (props) => ( 160 | 167 | ); 168 | 169 | const ErrorMessage = (props) => ; 170 | -------------------------------------------------------------------------------- /examples/4-native/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Blank Template", 4 | "slug": "native", 5 | "privacy": "public", 6 | "sdkVersion": "36.0.0", 7 | "platforms": ["ios", "android", "web"], 8 | "version": "1.0.0", 9 | "orientation": "portrait", 10 | "icon": "./assets/icon.png", 11 | "splash": { 12 | "image": "./assets/splash.png", 13 | "resizeMode": "contain", 14 | "backgroundColor": "#ffffff" 15 | }, 16 | "updates": { 17 | "fallbackToCacheTimeout": 0 18 | }, 19 | "assetBundlePatterns": ["**/*"], 20 | "ios": { 21 | "supportsTablet": true 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/4-native/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andyrichardson/fielder/07e6ec1062657c1122fc29b36464dc31b41af3e8/examples/4-native/assets/icon.png -------------------------------------------------------------------------------- /examples/4-native/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andyrichardson/fielder/07e6ec1062657c1122fc29b36464dc31b41af3e8/examples/4-native/assets/splash.png -------------------------------------------------------------------------------- /examples/4-native/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /examples/4-native/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject" 9 | }, 10 | "dependencies": { 11 | "@expo/vector-icons": "^10.0.6", 12 | "expo": "~36.0.0", 13 | "expo-font": "~8.0.0", 14 | "fielder": "^1.2.1", 15 | "native-base": "2.13.8", 16 | "react": "~16.9.0", 17 | "react-dom": "~16.9.0", 18 | "react-native": "https://github.com/expo/react-native/archive/sdk-36.0.0.tar.gz", 19 | "react-native-web": "~0.11.7" 20 | }, 21 | "devDependencies": { 22 | "babel-preset-expo": "~8.0.0", 23 | "@babel/core": "^7.0.0" 24 | }, 25 | "private": true 26 | } 27 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # 💁‍♀️ Examples 2 | 3 | Here are a few examples to get you started. 4 | 5 | Click the links below to see live examples on codesandbox.io! 6 | 7 | - [1. Basics](https://codesandbox.io/s/github/andyrichardson/fielder/tree/master/examples/1-basics?module=%2Fsrc%2Fform%2FForm.tsx) 8 | - [2. Multi-step](https://codesandbox.io/s/github/andyrichardson/fielder/tree/master/examples/2-multi-step?module=%2Fsrc%2Fregister-form%2FForm.tsx) 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fielder", 3 | "version": "0.0.0", 4 | "description": "A field-first form library for React and React Native", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "umd:main": "dist/index.umd.js", 8 | "types": "dist/index.d.ts", 9 | "sideEffects": false, 10 | "keywords": [ 11 | "fielder", 12 | "react", 13 | "form", 14 | "dynamic", 15 | "validation" 16 | ], 17 | "files": [ 18 | "LICENSE", 19 | "README.md", 20 | "CHANGELOG.md", 21 | "docs/src/pages", 22 | "dist", 23 | "preact/dist", 24 | "src", 25 | "preact/src", 26 | "preact/package.json" 27 | ], 28 | "scripts": { 29 | "start": "rollup -c rollup.config.js --watch", 30 | "test": "jest", 31 | "build": "npm run build:react && npm run build:preact", 32 | "build:react": "rm -rf dist && rollup -c rollup.config.js", 33 | "build:preact": "rm -rf preact/dist && PREACT=true rollup -c rollup.config.js", 34 | "check-formatting": "prettier --check \"./**/*.{ts,tsx,md,mdx,html}\" \"!./CHANGELOG.md\"", 35 | "lint": "eslint -c .eslintrc \"src/**.{ts,tsx}\"", 36 | "changelog": "docker run -it --rm -v \"$(pwd)\":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator -u andyrichardson -p fielder" 37 | }, 38 | "author": "Andy Richardson (andyrichardson)", 39 | "license": "MIT", 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/andyrichardson/fielder.git" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/andyrichardson/fielder/issues" 46 | }, 47 | "homepage": "https://fielder.andyrichardson.dev", 48 | "devDependencies": { 49 | "@babel/cli": "^7.13.0", 50 | "@babel/core": "^7.13.8", 51 | "@babel/plugin-syntax-typescript": "^7.12.13", 52 | "@types/jest": "^26.0.20", 53 | "@types/react": "^17.0.2", 54 | "@types/react-dom": "^17.0.1", 55 | "@types/react-test-renderer": "^17.0.1", 56 | "@typescript-eslint/eslint-plugin": "^4.15.2", 57 | "@typescript-eslint/parser": "^4.15.2", 58 | "eslint": "^7.21.0", 59 | "eslint-config-prettier": "^8.1.0", 60 | "eslint-plugin-react": "^7.22.0", 61 | "eslint-plugin-react-hooks": "^4.2.0", 62 | "jest": "^26.6.3", 63 | "preact": "^10.5.9", 64 | "prettier": "^2.2.1", 65 | "react": "^17.0.1", 66 | "react-dom": "^17.0.1", 67 | "react-test-renderer": "^17.0.1", 68 | "rollup": "^2.40.0", 69 | "rollup-plugin-terser": "^7.0.2", 70 | "rollup-plugin-typescript2": "^0.30.0", 71 | "ts-jest": "^26.5.2", 72 | "tslib": "^2.1.0", 73 | "typescript": "^4.2.2" 74 | }, 75 | "optionalDependencies": { 76 | "preact": ">=10.5.9", 77 | "react": ">=16.8.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /preact/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fielder", 3 | "version": "latest", 4 | "description": "A field-first form library for React and React Native", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.es.js", 7 | "umd:main": "./dist/index.umd.js", 8 | "types": "./dist/index.d.ts", 9 | "sideEffects": false, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/andyrichardson/fielder.git" 13 | }, 14 | "author": "Andy Richardson (andyrichardson)", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/andyrichardson/fielder/issues" 18 | }, 19 | "homepage": "https://github.com/andyrichardson/fielder#readme" 20 | } 21 | -------------------------------------------------------------------------------- /react-to-preact.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ types: t }) => { 2 | const imports = {}; 3 | 4 | console.log(t); 5 | return { 6 | name: 'test', 7 | visitor: { 8 | // Remap React -> Preact imports 9 | ImportDeclaration: { 10 | enter: function (path, state) { 11 | const filename = state.file.opts.sourceFileName; 12 | 13 | if (!imports[filename]) { 14 | imports[filename] = {}; 15 | } 16 | 17 | const fileImports = imports[filename]; 18 | 19 | if (path.node.source.value !== 'react') { 20 | return; 21 | } 22 | 23 | const specifiers = { 24 | preact: [], 25 | 'preact/hooks': [], 26 | }; 27 | 28 | path.node.specifiers.forEach((spec) => { 29 | if (/^use.*/.test(spec.imported.name)) { 30 | specifiers['preact/hooks'].push(spec); 31 | return; 32 | } 33 | 34 | if (spec.imported.name === 'FC') { 35 | spec.imported.name = 'FunctionalComponent'; 36 | } 37 | 38 | if (spec.imported.name === 'MutableRefObject') { 39 | spec.imported.name = 'RefObject'; 40 | } 41 | 42 | specifiers.preact.push(spec); 43 | }); 44 | 45 | const replacements = []; 46 | 47 | Object.keys(specifiers).forEach((key) => { 48 | if (specifiers[key].length === 0) { 49 | return; 50 | } 51 | 52 | if (fileImports[key] === undefined) { 53 | fileImports[key] = t.importDeclaration( 54 | specifiers[key], 55 | t.stringLiteral(key) 56 | ); 57 | replacements.push(fileImports[key]); 58 | return; 59 | } 60 | 61 | fileImports[key].specifiers.concat(specifiers[key]); 62 | }); 63 | 64 | if (!replacements.length) { 65 | path.remove(); 66 | return; 67 | } 68 | 69 | path.replaceWithMultiple(replacements); 70 | }, 71 | }, 72 | // Add onInput to useField type 73 | TSPropertySignature: { 74 | enter: function (path) { 75 | if (path.node.key.name === 'onChange') { 76 | const copy = t.cloneNode(path.node); 77 | copy.key.name = 'onInput'; 78 | path.insertBefore(copy); 79 | } 80 | }, 81 | }, 82 | // Add onInput to useField body 83 | VariableDeclaration: { 84 | enter: function (path) { 85 | const firstDec = path.node.declarations[0]; 86 | 87 | if (!firstDec || firstDec.id.name !== 'onChange') { 88 | return; 89 | } 90 | 91 | const onInput = t.variableDeclaration('const', [ 92 | t.variableDeclarator(t.identifier('onInput'), firstDec.id), 93 | ]); 94 | path.insertAfter(onInput); 95 | }, 96 | }, 97 | // Add onInput to useField memoised deps 98 | ArrayExpression: { 99 | enter: function (path) { 100 | if ( 101 | path.node.elements.find( 102 | (i) => t.isIdentifier(i) && i.name === 'onChange' 103 | ) 104 | ) { 105 | path.node.elements.push(t.identifier('onInput')); 106 | } 107 | }, 108 | }, 109 | ObjectExpression: { 110 | enter: function (path) { 111 | if ( 112 | path.node.properties.find( 113 | (i) => t.isObjectProperty(i) && i.key.name === 'onChange' 114 | ) 115 | ) { 116 | path.node.properties.push( 117 | t.objectProperty(t.identifier('onInput'), t.identifier('onInput')) 118 | ); 119 | } 120 | }, 121 | }, 122 | }, 123 | }; 124 | }; 125 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | import { execSync } from 'child_process'; 4 | 5 | const isPreact = process.env.PREACT === 'true'; 6 | const pkg = require(isPreact ? './preact/package.json' : './package.json'); 7 | const prefix = (arg) => (isPreact ? `./preact/${arg}` : arg); 8 | 9 | if (isPreact) { 10 | // Run preact transform 11 | execSync( 12 | '$(npm bin)/babel src --out-dir preact/src --plugins @babel/plugin-syntax-typescript,./react-to-preact.js --extensions .ts --out-file-extension .ts' 13 | ); 14 | } 15 | 16 | export default { 17 | input: isPreact ? 'preact/src/index.ts' : 'src/index.ts', 18 | output: [ 19 | { 20 | file: prefix(pkg.main), 21 | format: 'cjs', 22 | sourcemap: true, 23 | }, 24 | { 25 | file: prefix(pkg.module), 26 | format: 'es', 27 | sourcemap: true, 28 | }, 29 | { 30 | file: prefix(pkg['umd:main']), 31 | format: 'umd', 32 | sourcemap: true, 33 | name: 'fielder', 34 | globals: isPreact 35 | ? { preact: 'preact', 'preact/hooks': 'preact' } 36 | : { react: 'react' }, 37 | }, 38 | ], 39 | external: [ 40 | ...Object.keys(pkg.dependencies || {}), 41 | ...Object.keys(pkg.peerDependencies || {}), 42 | ], 43 | plugins: [ 44 | typescript({ 45 | tsconfigOverride: isPreact 46 | ? { exclude: ['./src'], include: ['./preact/src'] } 47 | : undefined, 48 | }), 49 | terser({}), 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /src/__snapshots__/useForm.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`initial call matches snapshot 1`] = ` 4 | Object { 5 | "blurField": [Function], 6 | "fields": Object {}, 7 | "isValid": true, 8 | "isValidating": false, 9 | "mountField": [Function], 10 | "premountField": [Function], 11 | "setFieldState": [Function], 12 | "setFieldValidation": [Function], 13 | "setFieldValue": [Function], 14 | "unmountField": [Function], 15 | "validateField": [Function], 16 | "validateSubmission": [Function], 17 | } 18 | `; 19 | 20 | exports[`on fromState matches snapshot 1`] = ` 21 | Object { 22 | "blurField": [Function], 23 | "fields": Object { 24 | "field1": Object { 25 | "_isActive": false, 26 | "hasBlurred": false, 27 | "hasChanged": false, 28 | "isValid": false, 29 | "isValidating": false, 30 | "name": "field1", 31 | "value": "value1", 32 | }, 33 | "field2": Object { 34 | "_isActive": false, 35 | "hasBlurred": false, 36 | "hasChanged": false, 37 | "isValid": false, 38 | "isValidating": false, 39 | "name": "field2", 40 | "value": "value2", 41 | }, 42 | }, 43 | "isValid": true, 44 | "isValidating": false, 45 | "mountField": [Function], 46 | "premountField": [Function], 47 | "setFieldState": [Function], 48 | "setFieldValidation": [Function], 49 | "setFieldValue": [Function], 50 | "unmountField": [Function], 51 | "validateField": [Function], 52 | "validateSubmission": [Function], 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /src/actions/blurField.ts: -------------------------------------------------------------------------------- 1 | import { FormSchemaType } from '../types'; 2 | import { ActionHandler } from './util'; 3 | 4 | /** Sets a field to `hasBlurred`. */ 5 | export type BlurFieldAction = { 6 | type: 'BLUR_FIELD'; 7 | config: BlurFieldArgs; 8 | }; 9 | 10 | export type BlurFieldArgs = { 11 | name: keyof T; 12 | }; 13 | 14 | /** Triggers a change to the given field. */ 15 | export const doBlurField: ActionHandler = (fields) => ({ 16 | name, 17 | }) => { 18 | const p = fields[name]; 19 | 20 | if (p === undefined) { 21 | throw Error('Cannot unmount non-mounted field'); 22 | } 23 | 24 | if (!p._isActive) { 25 | console.warn('Setting field attribute on inactive field.'); 26 | } 27 | 28 | return { 29 | ...fields, 30 | [name]: { 31 | ...p, 32 | hasBlurred: true, 33 | }, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/actions/mountField.ts: -------------------------------------------------------------------------------- 1 | import { FieldState, FormSchemaType } from '../types'; 2 | import { ActionHandler } from './util'; 3 | 4 | /** Mounts/remounts a field to the form. */ 5 | export type MountFieldAction = { 6 | type: 'MOUNT_FIELD'; 7 | config: MountFieldArgs; 8 | }; 9 | 10 | export type MountFieldArgs = { 11 | name: K; 12 | validate?: FieldState['_validate']; 13 | initialValue?: FieldState['value']; 14 | }; 15 | 16 | /** Initial and remount of field. */ 17 | export const doMountField: ActionHandler = (fields) => ({ 18 | name, 19 | validate, 20 | initialValue = undefined, 21 | }) => { 22 | const p = fields[name as string] || ({} as FieldState); 23 | 24 | if (p && p._isActive) { 25 | console.warn('Field is already mounted'); 26 | } 27 | 28 | return { 29 | ...fields, 30 | [name]: { 31 | ...p, 32 | name: name as string, 33 | _isActive: true, 34 | _validate: validate, 35 | isValid: true, 36 | isValidating: false, 37 | value: p.value !== undefined ? p.value : initialValue, 38 | error: p.error, 39 | hasBlurred: false, 40 | hasChanged: false, 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/actions/setFieldState.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction, FieldState, FormSchemaType } from '../types'; 2 | import { ActionHandler } from './util'; 3 | 4 | /** Set state of a field (without triggering validation). */ 5 | export type SetFieldStateAction = { 6 | type: 'SET_FIELD_STATE'; 7 | config: SetFieldStateArgs; 8 | }; 9 | 10 | export type SetFieldStateArgs = { 11 | name: K; 12 | state: SetStateAction>; 13 | }; 14 | 15 | export const doSetFieldState: ActionHandler = ( 16 | fields 17 | ) => ({ name, state }) => { 18 | const p = fields[name]; 19 | 20 | if (p === undefined) { 21 | throw Error('Cannot unmount non-mounted field'); 22 | } 23 | 24 | const newState = typeof state === 'function' ? state(p) : state; 25 | 26 | /** Same object reference was returned. */ 27 | if (newState === p) { 28 | return fields; 29 | } 30 | 31 | return { 32 | ...fields, 33 | [name]: newState, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/actions/setFieldValidation.ts: -------------------------------------------------------------------------------- 1 | import { FieldState, FormSchemaType } from '../types'; 2 | import { ActionHandler } from './util'; 3 | 4 | /** Sets the value of a field. */ 5 | export type SetFieldValidationAction = { 6 | type: 'SET_FIELD_VALIDATION'; 7 | config: SetFieldValidationArgs; 8 | }; 9 | 10 | export type SetFieldValidationArgs< 11 | T extends FormSchemaType, 12 | K extends keyof T 13 | > = { 14 | name: K; 15 | validation?: FieldState['_validate']; 16 | }; 17 | 18 | /** Triggers a change to the given field. */ 19 | export const doSetFieldValidation: ActionHandler = ( 20 | fields 21 | ) => ({ name, validation }) => { 22 | const p = fields[name as string]; 23 | 24 | if (p === undefined) { 25 | throw Error('Cannot set validation on non-mounted field'); 26 | } 27 | 28 | if (!p._isActive) { 29 | console.warn('Setting field validation for inactive field.'); 30 | } 31 | 32 | return { 33 | ...fields, 34 | [name]: { 35 | ...p, 36 | _validate: validation, 37 | }, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/actions/setFieldValue.ts: -------------------------------------------------------------------------------- 1 | import { ActionHandler } from './util'; 2 | import { FormSchemaType, SetStateAction } from '../types'; 3 | 4 | export type SetFieldValueAction = { 5 | type: 'SET_FIELD_VALUE'; 6 | config: SetFieldValueArgs; 7 | }; 8 | 9 | export type SetFieldValueArgs = { 10 | name: K; 11 | value: SetStateAction; 12 | }; 13 | 14 | /** Triggers a change to the given field. */ 15 | export const doSetFieldValue: ActionHandler = ( 16 | fields 17 | ) => ({ name, value }) => { 18 | const p = fields[name as string]; 19 | 20 | if (p === undefined) { 21 | throw Error('Cannot set value on non-mounted field'); 22 | } 23 | 24 | if (!p._isActive) { 25 | console.warn('Setting field value for inactive field.'); 26 | } 27 | 28 | return { 29 | ...fields, 30 | [name]: { 31 | ...p, 32 | value: typeof value === 'function' ? (value as any)(p.value) : value, 33 | hasChanged: true, 34 | }, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/actions/unmountField.ts: -------------------------------------------------------------------------------- 1 | import { FormSchemaType } from '../types'; 2 | import { ActionHandler } from './util'; 3 | 4 | /** Unmounts/removes a field to the form. */ 5 | export type UnmountFieldAction = { 6 | type: 'UNMOUNT_FIELD'; 7 | config: UnmountFieldArgs; 8 | }; 9 | 10 | export type UnmountFieldArgs = { 11 | name: keyof T; 12 | destroy?: boolean; 13 | }; 14 | 15 | /** Unmount of field. */ 16 | export const doUnmountField: ActionHandler = (fields) => ({ 17 | name, 18 | destroy = false, 19 | }) => { 20 | const p = fields[name as string]; 21 | 22 | if (p === undefined) { 23 | throw Error('Cannot unmount non-mounted field'); 24 | } 25 | 26 | if (destroy) { 27 | return Object.entries(fields).reduce( 28 | (state, [key, value]) => 29 | key === name ? state : { ...state, [key]: value }, 30 | {} 31 | ); 32 | } 33 | 34 | return { 35 | ...fields, 36 | [name]: { 37 | ...p, 38 | _isActive: false, 39 | }, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/actions/util.ts: -------------------------------------------------------------------------------- 1 | import { FieldsState } from '../types'; 2 | 3 | export type ActionHandler< 4 | T extends { type: string; config?: any }, 5 | Config = T extends { config: infer C } ? C : never 6 | > = (state: FieldsState) => (args: Config) => FieldsState; 7 | -------------------------------------------------------------------------------- /src/actions/validateField.ts: -------------------------------------------------------------------------------- 1 | import { FormSchemaType, ValidationTrigger } from '../types'; 2 | 3 | /** Trigger validation to be called on a given field. */ 4 | export type ValidateFieldAction = { 5 | type: 'VALIDATE_FIELD'; 6 | config: ValidateFieldArgs; 7 | }; 8 | 9 | export type ValidateFieldArgs = { 10 | name: keyof T; 11 | trigger?: ValidationTrigger; 12 | }; 13 | -------------------------------------------------------------------------------- /src/actions/validateSubmission.ts: -------------------------------------------------------------------------------- 1 | /** Trigger validation to be called on a given field. */ 2 | export type ValidateSubmissionAction = { 3 | type: 'VALIDATE_SUBMISSION'; 4 | }; 5 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { FormState } from './types'; 3 | 4 | export const FielderContext = createContext>(null as any); 5 | 6 | export const useFormContext = () => useContext(FielderContext); 7 | 8 | export const FielderProvider = FielderContext.Provider; 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { FielderContext, FielderProvider, useFormContext } from './context'; 2 | export * from './types'; 3 | export * from './useField'; 4 | export * from './useSubmit'; 5 | export * from './useForm'; 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SetFieldValueArgs } from './actions/setFieldValue'; 2 | import { ValidateFieldArgs } from './actions/validateField'; 3 | import { MountFieldArgs } from './actions/mountField'; 4 | import { BlurFieldArgs } from './actions/blurField'; 5 | import { UnmountFieldArgs } from './actions/unmountField'; 6 | import { SetFieldStateArgs } from './actions/setFieldState'; 7 | import { SetFieldValidationArgs } from './actions/setFieldValidation'; 8 | 9 | export type FormValue = string | boolean | number | string[]; 10 | 11 | export type FormSchemaType = Record; 12 | 13 | export type FieldsState = { 14 | [K in keyof T]: FieldState; 15 | }; 16 | 17 | export interface FormState { 18 | fields: FieldsState; 19 | /** All mounted fields are valid. */ 20 | isValid: boolean; 21 | /** Async validation currently active on a mounted fields. */ 22 | isValidating: boolean; 23 | /** Set value for a field. */ 24 | setFieldValue: (a: SetFieldValueArgs) => void; 25 | /** Trigger blur event for a mounted field. */ 26 | blurField: (a: BlurFieldArgs) => void; 27 | /** Force trigger validation on a mounted field. */ 28 | validateField: (a: ValidateFieldArgs) => void; 29 | 30 | /** Internal: Premount field during render */ 31 | premountField: ( 32 | a: MountFieldArgs 33 | ) => FieldState; 34 | /** Internal: Manually mount field. */ 35 | mountField: ( 36 | a: MountFieldArgs 37 | ) => FieldState; 38 | /** Internal: Manually unmount field. */ 39 | unmountField: (a: UnmountFieldArgs) => void; 40 | /** Internal: Manually set field state. */ 41 | setFieldState: (a: SetFieldStateArgs) => void; 42 | /** Internal: Set new field validation function */ 43 | setFieldValidation: ( 44 | a: SetFieldValidationArgs 45 | ) => void; 46 | 47 | /** Trigger submission validation. 48 | * 49 | * Throws on synchronous validation errors. 50 | * Returns promise if form contains async validation. 51 | */ 52 | validateSubmission: () => MaybePromise<{ 53 | state: FieldsState; 54 | errors: Record; 55 | }>; 56 | } 57 | 58 | export interface FieldState< 59 | T extends FormSchemaType | FormValue = FormValue, 60 | K extends keyof T = any, 61 | // Resolve value and schema form generics 62 | V extends FormValue = T extends FormSchemaType ? T[K] : T, 63 | S extends FormSchemaType = T extends FormSchemaType ? T : FormSchemaType 64 | > { 65 | /** The field is currently mounted. */ 66 | readonly _isActive: boolean; 67 | /** Validation function. */ 68 | readonly _validate?: ValidationFn | ObjectValidation; 69 | 70 | // Props 71 | /** Field name */ 72 | readonly name: K; 73 | /** Field value */ 74 | readonly value: V; 75 | 76 | // Meta 77 | /** Field error */ 78 | readonly error?: string; 79 | /** Field is currently valid. */ 80 | readonly isValid: boolean; 81 | /** Field is currently being validated (async). */ 82 | readonly isValidating: boolean; 83 | /** Field has been blurred since mount. */ 84 | readonly hasBlurred: boolean; 85 | /** Field has been changed since mount. */ 86 | readonly hasChanged: boolean; 87 | } 88 | 89 | /** 90 | * Events which trigger validation 91 | * 92 | * `mount`: Field has been mounted. 93 | * 94 | * `blur`: Field has had 'onBlur' event. 95 | * 96 | * `change`: Field has had 'onChange' event. 97 | * 98 | * `update`: The value of another field in the form has changed. 99 | * 100 | * `submit`: Submission has begun. 101 | */ 102 | export type ValidationTrigger = 103 | | 'mount' 104 | | 'blur' 105 | | 'change' 106 | | 'update' 107 | | 'submit'; 108 | 109 | /** Arguments passed to a validation function */ 110 | export type ValidationArgs< 111 | T extends FormValue = FormValue, 112 | S extends FormSchemaType = FormSchemaType 113 | > = { 114 | trigger: ValidationTrigger; 115 | value: T; 116 | form: FieldsState; 117 | }; 118 | 119 | /** Handler for validation event */ 120 | export type ValidationFn< 121 | T extends FormValue = FormValue, 122 | S extends FormSchemaType = FormSchemaType 123 | > = (args: ValidationArgs) => void | Promise; 124 | 125 | /** A map of validation events corresponding to a function. */ 126 | export type ObjectValidation< 127 | T extends FormValue = FormValue, 128 | S extends FormSchemaType = FormSchemaType 129 | > = { 130 | [k in ValidationTrigger]?: ValidationFn; 131 | }; 132 | 133 | type MaybePromise = T | Promise; 134 | 135 | export type Dispatch = (a: T) => void; 136 | 137 | export type SetStateAction = T | ((a: T) => T); 138 | -------------------------------------------------------------------------------- /src/useField.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { create } from 'react-test-renderer'; 3 | import { useField, UseFieldResponse } from './useField'; 4 | import { FielderContext } from './context'; 5 | 6 | let context = { 7 | fields: {}, 8 | premountField: jest.fn(), 9 | mountField: jest.fn(), 10 | unmountField: jest.fn(), 11 | blurField: jest.fn(), 12 | setFieldValue: jest.fn(), 13 | setFieldState: jest.fn(), 14 | setFieldValidation: jest.fn(), 15 | }; 16 | let response: UseFieldResponse; 17 | let args: any; 18 | 19 | const F: FC = ({ children }) => { 20 | response = useField(args); 21 | 22 | return <>{children}; 23 | }; 24 | 25 | const Fixture: FC = ({ children }) => { 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | beforeEach(jest.clearAllMocks); 34 | 35 | beforeEach(() => { 36 | context = { 37 | fields: {}, 38 | premountField: jest.fn(() => ({ 39 | value: 'abc', 40 | isValid: true, 41 | isValidating: false, 42 | hasChanged: false, 43 | hasBlurred: false, 44 | })), 45 | mountField: jest.fn(), 46 | unmountField: jest.fn(), 47 | blurField: jest.fn(), 48 | setFieldValue: jest.fn(), 49 | setFieldState: jest.fn(), 50 | setFieldValidation: jest.fn(), 51 | }; 52 | }); 53 | 54 | describe('on mount', () => { 55 | beforeEach(() => 56 | context.premountField.mockReturnValue({ 57 | value: 'abc', 58 | isValid: false, 59 | isValidating: false, 60 | hasChanged: false, 61 | hasBlurred: false, 62 | }) 63 | ); 64 | 65 | it('calls mount field', () => { 66 | args = { name: 'someField', initialValue: 'abc', validate: jest.fn() }; 67 | create(); 68 | 69 | expect(context.premountField).toBeCalledTimes(1); 70 | expect(context.mountField).toBeCalledTimes(1); 71 | expect(context.setFieldValidation).toBeCalledTimes(0); 72 | }); 73 | 74 | it('returns state from "mountField" response', () => { 75 | args = { name: 'someField', initialValue: 'abc', validate: jest.fn() }; 76 | create(); 77 | 78 | expect(response).toMatchInlineSnapshot(` 79 | Array [ 80 | Object { 81 | "name": "someField", 82 | "onBlur": [Function], 83 | "onChange": [Function], 84 | "value": "abc", 85 | }, 86 | Object { 87 | "error": undefined, 88 | "hasBlurred": false, 89 | "hasChanged": false, 90 | "isValid": false, 91 | "isValidating": false, 92 | }, 93 | ] 94 | `); 95 | }); 96 | }); 97 | 98 | describe('on validation changed', () => { 99 | beforeEach(() => 100 | context.mountField.mockReturnValue({ 101 | value: 'abc', 102 | isValid: true, 103 | isValidating: false, 104 | hasChanged: false, 105 | hasBlurred: false, 106 | }) 107 | ); 108 | 109 | it('calls setFieldValidation', () => { 110 | const fn1 = () => true; 111 | const fn2 = () => true; 112 | 113 | args = { name: 'someField', initialValue: 'abc', validate: fn1 }; 114 | const wrapper = create(); 115 | 116 | args = { ...args, validate: fn2 }; 117 | wrapper.update(); 118 | 119 | expect(context.mountField).toBeCalledTimes(1); 120 | expect(context.setFieldValidation).toBeCalledTimes(1); 121 | }); 122 | }); 123 | 124 | describe('on unmount', () => { 125 | it('calls unmountField', () => { 126 | args = { name: 'someField' }; 127 | const wrapper = create(); 128 | wrapper.unmount(); 129 | 130 | expect(context.unmountField).toBeCalledTimes(1); 131 | expect(context.unmountField).toBeCalledWith({ 132 | name: args.name, 133 | destroy: false, 134 | }); 135 | }); 136 | 137 | it('calls unmountField and destroys value', () => { 138 | args = { name: 'someField', destroyOnUnmount: true }; 139 | const wrapper = create(); 140 | wrapper.unmount(); 141 | 142 | expect(context.unmountField).toBeCalledTimes(1); 143 | expect(context.unmountField).toBeCalledWith({ 144 | name: args.name, 145 | destroy: true, 146 | }); 147 | }); 148 | 149 | it('calls unmountField and passes destroy value after change', () => { 150 | args = { name: 'someField', destroyOnUnmount: false }; 151 | const wrapper = create(); 152 | args = { ...args, destroyOnUnmount: true }; 153 | wrapper.update(); 154 | wrapper.unmount(); 155 | 156 | expect(context.unmountField).toBeCalledTimes(1); 157 | expect(context.unmountField).toBeCalledWith({ 158 | name: args.name, 159 | destroy: true, 160 | }); 161 | }); 162 | }); 163 | 164 | describe('on blur', () => { 165 | beforeEach(() => { 166 | args = { name: 'someField' }; 167 | create(); 168 | response[0].onBlur(); 169 | }); 170 | 171 | it('calls blurField', () => { 172 | expect(context.blurField).toBeCalledTimes(1); 173 | expect(context.blurField).toBeCalledWith({ 174 | name: args.name, 175 | }); 176 | }); 177 | }); 178 | 179 | describe('on change', () => { 180 | describe('on initial value is boolean', () => { 181 | beforeEach(() => { 182 | args = { name: 'someField', initialValue: true }; 183 | }); 184 | 185 | it('calls setFieldValue with toggle fn', () => { 186 | const value = 'newval'; 187 | const target = document.createElement('input'); 188 | target.type = 'text'; 189 | target.value = value; 190 | 191 | create(); 192 | response[0].onChange({ 193 | currentTarget: target, 194 | } as any); 195 | 196 | const action = context.setFieldValue.mock.calls[0][0].value; 197 | expect(context.setFieldValue).toBeCalledTimes(1); 198 | expect(action(false)).toBe(true); 199 | expect(action(true)).toBe(false); 200 | }); 201 | }); 202 | 203 | describe('on event arg', () => { 204 | beforeEach(() => { 205 | args = { name: 'someField' }; 206 | }); 207 | 208 | it('calls setFieldValue with field name', () => { 209 | const value = 'newval'; 210 | const target = document.createElement('input'); 211 | target.type = 'text'; 212 | target.value = value; 213 | 214 | create(); 215 | response[0].onChange({ 216 | currentTarget: target, 217 | } as any); 218 | 219 | expect(context.setFieldValue).toBeCalledTimes(1); 220 | expect(context.setFieldValue).toBeCalledWith( 221 | expect.objectContaining({ 222 | name: args.name, 223 | }) 224 | ); 225 | }); 226 | }); 227 | 228 | describe('on value arg', () => { 229 | beforeEach(() => { 230 | args = { name: 'someField' }; 231 | }); 232 | 233 | it('calls setFieldValue with field name', () => { 234 | const value = 'newval'; 235 | 236 | create(); 237 | response[0].onChange(value); 238 | 239 | expect(context.setFieldValue).toBeCalledTimes(1); 240 | expect(context.setFieldValue).toBeCalledWith( 241 | expect.objectContaining({ 242 | name: args.name, 243 | }) 244 | ); 245 | }); 246 | }); 247 | 248 | describe('on value action dispatch', () => { 249 | beforeEach(() => { 250 | args = { name: 'someField' }; 251 | }); 252 | 253 | describe('on no existing value', () => { 254 | it('returns change value', () => { 255 | const value = 'newval'; 256 | 257 | create(); 258 | response[0].onChange(value); 259 | 260 | const action = context.setFieldValue.mock.calls[0][0].value; 261 | expect(action()).toEqual(value); 262 | }); 263 | }); 264 | 265 | describe('on existing value is string', () => { 266 | it('returns change value', () => { 267 | const oldValue = 'oldval'; 268 | const value = 'newval'; 269 | 270 | create(); 271 | response[0].onChange(value); 272 | 273 | const action = context.setFieldValue.mock.calls[0][0].value; 274 | expect(action(oldValue)).toEqual(value); 275 | }); 276 | }); 277 | 278 | describe('on existing value is array (without new value)', () => { 279 | it('appends change value to array', () => { 280 | const oldValue = ['oldval']; 281 | const value = 'newval'; 282 | 283 | create(); 284 | response[0].onChange(value); 285 | 286 | const action = context.setFieldValue.mock.calls[0][0].value; 287 | expect(action(oldValue)).toEqual([...oldValue, value]); 288 | }); 289 | }); 290 | 291 | describe('on existing value is array (with change value)', () => { 292 | it('removes change value from array', () => { 293 | const oldValue = ['newval', 'oldval']; 294 | const value = 'newval'; 295 | 296 | create(); 297 | response[0].onChange(value); 298 | 299 | const action = context.setFieldValue.mock.calls[0][0].value; 300 | expect(action(oldValue)).toEqual(oldValue.filter((v) => v !== value)); 301 | }); 302 | }); 303 | }); 304 | }); 305 | -------------------------------------------------------------------------------- /src/useField.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback, useMemo, useRef } from 'react'; 2 | import { useLayoutEffect } from './util'; 3 | import { FielderContext } from './context'; 4 | import { 5 | FormState, 6 | FieldState, 7 | FormSchemaType, 8 | ObjectValidation, 9 | ValidationFn, 10 | FormValue, 11 | } from './types'; 12 | 13 | export type UseFieldProps = { 14 | /** Field name. */ 15 | readonly name: string; 16 | /** Field value. */ 17 | readonly value: T; 18 | /** Change event handler. */ 19 | readonly onChange: (e: { currentTarget: { value: any } } | T) => void; 20 | /** Blur event handler (sets blurred state). */ 21 | readonly onBlur: () => void; 22 | }; 23 | 24 | export type UseFieldMeta = { 25 | /** Field error. */ 26 | readonly error?: FieldState['error']; 27 | /** Field is currently valid. */ 28 | readonly isValid: FieldState['isValid']; 29 | /** Field is currently being validated (async). */ 30 | readonly isValidating: FieldState['isValidating']; 31 | /** Field has been blurred since mount. */ 32 | readonly hasBlurred: FieldState['hasBlurred']; 33 | /** Field has been changed since mount. */ 34 | readonly hasChanged: FieldState['hasChanged']; 35 | }; 36 | 37 | export type UseFieldArgs< 38 | S extends FormSchemaType = FormSchemaType, 39 | K extends keyof S = keyof S, 40 | V extends FormValue = S[K] 41 | > = { 42 | /** Unique identifier for field. */ 43 | readonly name: K; 44 | /** Starting value. */ 45 | readonly initialValue: V; 46 | /** Validation function (throws errors). */ 47 | readonly validate?: ObjectValidation | ValidationFn; 48 | /** Should destroy value when useField hook is unmounted. */ 49 | readonly destroyOnUnmount?: boolean; 50 | }; 51 | 52 | export type UseFieldResponse = readonly [UseFieldProps, UseFieldMeta]; 53 | 54 | export const useField = ({ 55 | name, 56 | validate, 57 | initialValue, 58 | destroyOnUnmount = false, 59 | }: UseFieldArgs): UseFieldResponse => { 60 | const destroyRef = useRef(destroyOnUnmount); 61 | const initialMount = useRef(true); 62 | const { 63 | fields, 64 | blurField, 65 | premountField, 66 | mountField, 67 | unmountField, 68 | setFieldValue, 69 | setFieldValidation, 70 | } = useContext>(FielderContext); 71 | 72 | // Set unchanging initial values 73 | const initial = useMemo(() => ({ name, value: initialValue, validate }), []); // eslint-disable-line 74 | 75 | const field = useMemo(() => { 76 | if (initialMount.current) { 77 | // Simulate mounting without committing to state 78 | return premountField({ 79 | name: initial.name, 80 | initialValue: initial.value, 81 | validate: initial.validate, 82 | }); 83 | } 84 | 85 | return fields[initial.name]; 86 | }, [initial.name, initial.value, initial.validate, premountField, fields]); 87 | 88 | useLayoutEffect(() => { 89 | mountField({ 90 | name: initial.name, 91 | initialValue: initial.value, 92 | validate: initial.validate, 93 | }); 94 | }, [initial.name, initial.validate, initial.value, mountField]); 95 | 96 | useLayoutEffect( 97 | () => () => { 98 | initialMount.current = false; 99 | }, 100 | [] 101 | ); 102 | 103 | useMemo(() => (destroyRef.current = destroyOnUnmount), [destroyOnUnmount]); 104 | 105 | useLayoutEffect( 106 | () => () => { 107 | unmountField({ name: initial.name, destroy: destroyRef.current }); 108 | }, 109 | [unmountField, initial.name] 110 | ); 111 | 112 | /** Update field state on validation config change. */ 113 | useLayoutEffect(() => { 114 | if (initialMount.current) { 115 | initialMount.current = false; 116 | return; 117 | } 118 | 119 | setFieldValidation({ name: initial.name, validation: validate }); 120 | }, [validate, initial.name, setFieldValidation]); 121 | 122 | const onBlur = useCallback(() => blurField({ name: initial.name }), [ 123 | initial.name, 124 | blurField, 125 | ]); 126 | 127 | const onChange = useCallback( 128 | (e) => { 129 | // If initial value is boolean, 130 | // toggle value on change (i.e. checkbox) 131 | if (typeof initial.value === 'boolean') { 132 | return setFieldValue({ 133 | name: initial.name, 134 | value: (v: boolean) => !v, 135 | }); 136 | } 137 | 138 | const value = 139 | typeof e === 'object' && 'currentTarget' in e 140 | ? e.currentTarget.value 141 | : e; 142 | 143 | return setFieldValue({ 144 | name: initial.name, 145 | value: (previousValue: any) => { 146 | if (!Array.isArray(previousValue)) { 147 | return value; 148 | } 149 | 150 | return previousValue.includes(value) 151 | ? previousValue.filter((v) => v !== value) 152 | : [...previousValue, value]; 153 | }, 154 | }); 155 | }, 156 | [initial.name, initial.value, setFieldValue] 157 | ); 158 | 159 | const { value, error, isValid, isValidating, hasChanged, hasBlurred } = field; 160 | 161 | return useMemo( 162 | () => [ 163 | { name: initial.name, value, onBlur, onChange }, 164 | { error, isValid, isValidating, hasBlurred, hasChanged }, 165 | ], 166 | [ 167 | initial.name, 168 | value, 169 | onBlur, 170 | onChange, 171 | error, 172 | isValid, 173 | isValidating, 174 | hasChanged, 175 | hasBlurred, 176 | ] 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /src/useForm.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef, MutableRefObject } from 'react'; 2 | import { 3 | Dispatch, 4 | FormState, 5 | FieldState, 6 | FieldsState, 7 | FormSchemaType, 8 | } from './types'; 9 | import { BlurFieldAction, doBlurField } from './actions/blurField'; 10 | import { MountFieldAction, doMountField } from './actions/mountField'; 11 | import { SetFieldStateAction, doSetFieldState } from './actions/setFieldState'; 12 | import { 13 | SetFieldValidationAction, 14 | doSetFieldValidation, 15 | } from './actions/setFieldValidation'; 16 | import { SetFieldValueAction, doSetFieldValue } from './actions/setFieldValue'; 17 | import { UnmountFieldAction, doUnmountField } from './actions/unmountField'; 18 | import { ValidateFieldAction } from './actions/validateField'; 19 | import { ValidateSubmissionAction } from './actions/validateSubmission'; 20 | import { applyValidationToState } from './validation/applyValidationToState'; 21 | import { batchValidationErrors } from './validation/batchValidationErrors'; 22 | import { useSynchronousReducer } from './useSynchronousReducer'; 23 | 24 | export type FormAction = 25 | | BlurFieldAction 26 | | MountFieldAction 27 | | SetFieldStateAction 28 | | SetFieldValidationAction 29 | | SetFieldValueAction 30 | | UnmountFieldAction 31 | | ValidateFieldAction 32 | | ValidateSubmissionAction; 33 | 34 | export type UseFormOpts = { fromState?: Record }; 35 | 36 | export const useForm = ( 37 | opts: UseFormOpts = {} 38 | ): FormState => { 39 | /** Async validation promise updated synchronously after every dispatch. */ 40 | const promiseRef = useRef> | undefined>(); 41 | /** Reference to dispatch for forwarding closure. */ 42 | const dispatchRef = useRef>(); 43 | 44 | const handleAsyncValidation = useMemo( 45 | () => createHandleAsyncValidation(dispatchRef), 46 | [] 47 | ); 48 | 49 | const initialState = useMemo( 50 | () => 51 | opts.fromState 52 | ? Object.entries(opts.fromState).reduce( 53 | (p, [key, value]) => ({ 54 | ...p, 55 | [key]: { 56 | _isActive: false, 57 | name: key, 58 | value, 59 | isValid: false, 60 | isValidating: false, 61 | hasBlurred: false, 62 | hasChanged: false, 63 | }, 64 | }), 65 | {} 66 | ) 67 | : {}, 68 | [] 69 | ); 70 | 71 | const [fields, dispatch] = useSynchronousReducer< 72 | FieldsState, 73 | FormAction 74 | >((state, action) => { 75 | const newState = applyActionToState(state, action); 76 | const { state: validatedState, promises } = applyValidationToState( 77 | newState, 78 | action 79 | ); 80 | 81 | if (Object.keys(promises).length > 0) { 82 | // Maybe we should batch async updates caused by a single action 83 | Object.entries(promises).map(([name, promise]) => 84 | handleAsyncValidation(name, promise) 85 | ); 86 | } 87 | 88 | promiseRef.current = promises; 89 | return validatedState; 90 | }, initialState); 91 | 92 | useMemo(() => (dispatchRef.current = dispatch), [dispatch]); 93 | 94 | /** Retrieves field state for initial mount (before mounted field is added to state) */ 95 | const premountField = useCallback['premountField']>( 96 | (config) => { 97 | const action = { 98 | type: 'MOUNT_FIELD', 99 | config, 100 | } as const; 101 | 102 | const newState = applyActionToState(fields, action); 103 | const { state: validatedState } = applyValidationToState( 104 | newState, 105 | action 106 | ); 107 | 108 | // Throw away async validation on mount and 109 | // wait for mountField call during commit 110 | 111 | return validatedState[config.name] as any; 112 | }, 113 | [fields] 114 | ); 115 | 116 | const mountField = useCallback['mountField']>( 117 | (config) => dispatch({ type: 'MOUNT_FIELD', config })[config.name] as any, 118 | [dispatch] 119 | ); 120 | 121 | const unmountField = useCallback['unmountField']>( 122 | (config) => dispatch({ type: 'UNMOUNT_FIELD', config }), 123 | [dispatch] 124 | ); 125 | 126 | const setFieldValue = useCallback['setFieldValue']>( 127 | (config) => dispatch({ type: 'SET_FIELD_VALUE', config }), 128 | [dispatch] 129 | ); 130 | 131 | const setFieldState = useCallback['setFieldState']>( 132 | (config) => dispatch({ type: 'SET_FIELD_STATE', config }), 133 | [dispatch] 134 | ); 135 | 136 | const blurField = useCallback['blurField']>( 137 | (config) => dispatch({ type: 'BLUR_FIELD', config: config }), 138 | [dispatch] 139 | ); 140 | 141 | const setFieldValidation = useCallback['setFieldValidation']>( 142 | (config) => dispatch({ type: 'SET_FIELD_VALIDATION', config }), 143 | [dispatch] 144 | ); 145 | 146 | const validateField = useCallback['validateField']>( 147 | (config) => dispatch({ type: 'VALIDATE_FIELD', config }), 148 | [dispatch] 149 | ); 150 | 151 | const validateSubmission = useCallback< 152 | FormState['validateSubmission'] 153 | >(() => { 154 | const newState = dispatch({ type: 'VALIDATE_SUBMISSION' }); 155 | const errors = batchValidationErrors({ 156 | state: newState, 157 | promises: promiseRef.current, 158 | }); 159 | 160 | if (errors instanceof Promise) { 161 | return errors.then((errors) => ({ 162 | state: newState, 163 | errors, 164 | })); 165 | } 166 | 167 | return { 168 | state: newState, 169 | errors, 170 | }; 171 | }, [dispatch]); 172 | 173 | const mountedFields = useMemo( 174 | () => 175 | Object.values(fields as FieldsState).filter( 176 | (f) => !(f === undefined || !f._isActive) 177 | ) as FieldState[], 178 | [fields] 179 | ); 180 | const isValid = useMemo(() => mountedFields.every((f) => f.isValid), [ 181 | mountedFields, 182 | ]); 183 | 184 | const isValidating = useMemo( 185 | () => mountedFields.some((f) => f.isValidating), 186 | [mountedFields] 187 | ); 188 | 189 | return useMemo( 190 | () => ({ 191 | fields, 192 | isValid, 193 | isValidating, 194 | premountField, 195 | setFieldValue, 196 | blurField, 197 | validateField, 198 | validateSubmission, 199 | mountField, 200 | unmountField, 201 | setFieldState, 202 | setFieldValidation, 203 | }), 204 | [ 205 | fields, 206 | isValid, 207 | isValidating, 208 | premountField, 209 | setFieldValue, 210 | blurField, 211 | validateField, 212 | validateSubmission, 213 | mountField, 214 | unmountField, 215 | setFieldState, 216 | setFieldValidation, 217 | ] 218 | ); 219 | }; 220 | 221 | /** Triggers primary action to state. */ 222 | const applyActionToState = (s: FieldsState, a: FormAction) => { 223 | if (a.type === 'MOUNT_FIELD') { 224 | return doMountField(s)(a.config); 225 | } 226 | 227 | if (a.type === 'UNMOUNT_FIELD') { 228 | return doUnmountField(s)(a.config); 229 | } 230 | 231 | if (a.type === 'SET_FIELD_VALUE') { 232 | return doSetFieldValue(s)(a.config); 233 | } 234 | 235 | if (a.type === 'BLUR_FIELD') { 236 | return doBlurField(s)(a.config); 237 | } 238 | 239 | if (a.type === 'SET_FIELD_STATE') { 240 | return doSetFieldState(s)(a.config); 241 | } 242 | 243 | if (a.type === 'SET_FIELD_VALIDATION') { 244 | return doSetFieldValidation(s)(a.config); 245 | } 246 | 247 | return s; 248 | }; 249 | 250 | /** Tracks async validation per field and updates state on completion. */ 251 | const createHandleAsyncValidation = ( 252 | dispatch: MutableRefObject | undefined> 253 | ) => { 254 | let promises: Record> = {}; 255 | 256 | return (name: string, validationPromise: Promise) => { 257 | // Add promise to collection 258 | promises = { 259 | ...promises, 260 | [name]: validationPromise, 261 | }; 262 | 263 | const validationCallback = (isError: boolean) => (response: any) => { 264 | if (!dispatch || !dispatch.current) { 265 | console.warn( 266 | 'Unable to update validation state. Dispatch not available.' 267 | ); 268 | return; 269 | } 270 | 271 | // Newer validation promise has been called 272 | if (promises[name] !== validationPromise) { 273 | return; 274 | } 275 | 276 | const isValid = !isError; 277 | 278 | dispatch.current({ 279 | type: 'SET_FIELD_STATE', 280 | config: { 281 | name, 282 | state: (s: any) => ({ 283 | ...s, 284 | isValidating: false, 285 | isValid, 286 | error: isValid ? undefined : response.message, 287 | }), 288 | }, 289 | }); 290 | }; 291 | 292 | validationPromise 293 | .then(validationCallback(false)) 294 | .catch(validationCallback(true)); 295 | }; 296 | }; 297 | -------------------------------------------------------------------------------- /src/useSubmit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useMemo, useState } from 'react'; 2 | import { FieldsState } from './types'; 3 | import { FielderContext } from './context'; 4 | 5 | type UseSubmitResponse = { 6 | /** Indicates when async `submit` validation is in progress. */ 7 | isValidating: boolean; 8 | /** 9 | * Is set to true immediately upon call of the handleSubmit function. 10 | * 11 | * _Does not imply the form is valid or that submit validation was successful_ 12 | **/ 13 | hasSubmitted: boolean; 14 | /** 15 | * Wrapper for provided submit function. 16 | */ 17 | handleSubmit: () => void; 18 | }; 19 | 20 | /** 21 | * Wrapper utility for triggering submission validation. 22 | * 23 | * - Constructs values object to provided submission function 24 | * - Guards submission function until sync/async validation has completed 25 | * - Indicates state of submission 26 | */ 27 | export const useSubmit = >( 28 | /** A handler for */ 29 | handler: (values: T) => void 30 | ): UseSubmitResponse => { 31 | const [state, setState] = useState({ 32 | isValidating: false, 33 | hasSubmitted: false, 34 | }); 35 | const { validateSubmission } = useContext(FielderContext); 36 | 37 | const handleSubmit = useCallback(async () => { 38 | const possibleProm = validateSubmission(); 39 | setState({ 40 | isValidating: possibleProm instanceof Promise, 41 | hasSubmitted: true, 42 | }); 43 | 44 | const { state, errors } = await possibleProm; 45 | setState((s) => ({ ...s, isValidating: false })); 46 | 47 | // No errors - call submit handler 48 | if (Object.keys(errors).length === 0) { 49 | handler(stateToValues(state) as T); 50 | } 51 | }, [validateSubmission, handler]); 52 | 53 | return useMemo(() => ({ ...state, handleSubmit } as const), [ 54 | state, 55 | handleSubmit, 56 | ]); 57 | }; 58 | 59 | const stateToValues = (state: FieldsState) => 60 | Object.entries(state).reduce( 61 | (acc, [key, field]) => ({ 62 | ...acc, 63 | [key]: field.value, 64 | }), 65 | {} 66 | ); 67 | -------------------------------------------------------------------------------- /src/useSynchronousReducer.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef } from 'react'; 2 | 3 | export const useSynchronousReducer = ( 4 | reducer: (s: S, a: A) => S, 5 | initialState: S 6 | ) => { 7 | const stateRef = useRef(initialState); 8 | const [state, setState] = useState(stateRef.current); 9 | 10 | const dispatch = useCallback<(a: A) => S>((action) => { 11 | stateRef.current = reducer(stateRef.current, action); 12 | setState(stateRef.current); 13 | return stateRef.current; 14 | }, []); // eslint-disable-line react-hooks/exhaustive-deps 15 | 16 | return [state, dispatch] as const; 17 | }; 18 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect as useLayoutEffectOriginal, useEffect } from 'react'; 2 | 3 | export const useLayoutEffect = 4 | typeof window !== 'undefined' ? useLayoutEffectOriginal : useEffect; 5 | -------------------------------------------------------------------------------- /src/validation/applyValidationToState.ts: -------------------------------------------------------------------------------- 1 | import { FieldsState, FieldState, ValidationTrigger } from '../types'; 2 | import { FormAction } from '../useForm'; 3 | 4 | /** Triggers validation on fields items. */ 5 | export const applyValidationToState = ( 6 | state: FieldsState, 7 | action: FormAction 8 | ) => { 9 | return Object.keys(state).reduce<{ 10 | state: FieldsState; 11 | promises: Record>; 12 | }>( 13 | ({ state, promises }, key) => { 14 | const field = state[key]; 15 | const validationTrigger = getValidationTriggerForField(action, field); 16 | const validationFn = getFieldValidationFn(validationTrigger, field); 17 | 18 | if (!validationFn || !validationTrigger) { 19 | return { state, promises }; 20 | } 21 | 22 | try { 23 | const validateResponse = validationFn({ 24 | trigger: validationTrigger, 25 | value: field.value, 26 | form: state, 27 | }); 28 | 29 | if (validateResponse instanceof Promise) { 30 | return { 31 | promises: { 32 | ...promises, 33 | [key]: validateResponse, 34 | }, 35 | state: { 36 | ...state, 37 | [key]: { 38 | ...field, 39 | isValidating: true, 40 | }, 41 | }, 42 | }; 43 | } 44 | 45 | return { 46 | promises, 47 | state: { 48 | ...state, 49 | [key]: { 50 | ...field, 51 | isValid: true, 52 | isValidating: false, 53 | error: undefined, 54 | }, 55 | }, 56 | }; 57 | } catch (err: any) { 58 | return { 59 | promises, 60 | state: { 61 | ...state, 62 | [key]: { 63 | ...field, 64 | isValid: false, 65 | isValidating: false, 66 | error: err && err.message ? err.message : err, 67 | }, 68 | }, 69 | }; 70 | } 71 | }, 72 | { state, promises: {} } 73 | ); 74 | }; 75 | 76 | const getFieldValidationFn = ( 77 | trigger: ValidationTrigger | undefined, 78 | field: FieldState 79 | ) => { 80 | if (!trigger) { 81 | return; 82 | } 83 | 84 | if (typeof field._validate === 'function') { 85 | return field._validate; 86 | } 87 | 88 | if (typeof field._validate === 'object') { 89 | return field._validate[trigger]; 90 | } 91 | }; 92 | 93 | /** Return trigger for validation on field (may be undefined) */ 94 | const getValidationTriggerForField = ( 95 | action: FormAction, 96 | field: FieldState 97 | ) => { 98 | if (!field || !field._isActive || !field._validate) { 99 | return; 100 | } 101 | 102 | // Global change - applies to all 103 | if (action.type === 'VALIDATE_SUBMISSION') { 104 | return 'submit'; 105 | } 106 | 107 | // Global change - applies to all 108 | if (action.type === 'SET_FIELD_VALUE' && action.config.name !== field.name) { 109 | return 'update'; 110 | } 111 | 112 | // All other actions are field specific 113 | if (action.config.name !== field.name) { 114 | return; 115 | } 116 | 117 | if (action.type === 'MOUNT_FIELD') { 118 | return 'mount'; 119 | } 120 | 121 | if (action.type === 'BLUR_FIELD') { 122 | return 'blur'; 123 | } 124 | 125 | if (action.type === 'SET_FIELD_VALUE') { 126 | return 'change'; 127 | } 128 | 129 | // On validation update - try to rerun latest field validation trigger 130 | if (action.type === 'SET_FIELD_VALIDATION' && field.hasBlurred) { 131 | return 'blur'; 132 | } 133 | 134 | if (action.type === 'SET_FIELD_VALIDATION' && field.hasChanged) { 135 | return 'change'; 136 | } 137 | 138 | if (action.type === 'SET_FIELD_VALIDATION') { 139 | return 'mount'; 140 | } 141 | 142 | if (action.type === 'VALIDATE_FIELD') { 143 | return action.config.trigger || 'change'; 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /src/validation/batchValidationErrors.ts: -------------------------------------------------------------------------------- 1 | import { FieldsState } from '../types'; 2 | 3 | /** Joins all validation errors (sync/async) into a single validation function */ 4 | export const batchValidationErrors = ({ 5 | state, 6 | promises, 7 | }: { 8 | state: FieldsState; 9 | promises?: Record>; 10 | }) => { 11 | const syncErrors = getErrorsFromState(state); 12 | 13 | if (!promises || Object.keys(promises).length === 0) { 14 | return syncErrors; 15 | } 16 | 17 | return getErrorsFromPromises(promises).then((errs) => 18 | cleanupKeys({ 19 | ...syncErrors, 20 | ...errs, // Intentionally set some errors to undefined 21 | }) 22 | ); 23 | }; 24 | 25 | const getErrorsFromState = (state: FieldsState) => 26 | Object.entries(state).reduce((errors, [name, field]) => { 27 | if (field._isActive && field.error) { 28 | return { 29 | ...errors, 30 | [name]: field.error, 31 | }; 32 | } 33 | 34 | return errors; 35 | }, {}); 36 | 37 | const getErrorsFromPromises = async ( 38 | promises: Record> 39 | ) => { 40 | const results = await Promise.all( 41 | Object.entries(promises).map< 42 | Promise<{ key: string; error: string | undefined }> 43 | >(([key, promise]) => 44 | promise 45 | .then(() => ({ key, error: undefined })) 46 | .catch((err) => ({ key, error: err.message })) 47 | ) 48 | ); 49 | 50 | return results.reduce( 51 | (errors, result) => ({ 52 | ...errors, 53 | [result.key]: result.error, 54 | }), 55 | {} 56 | ); 57 | }; 58 | 59 | /** Remove keys from object where value is undefined */ 60 | const cleanupKeys = (arg: object) => 61 | Object.entries(arg).reduce((acc, [key, value]) => { 62 | if (value === undefined) { 63 | return acc; 64 | } 65 | 66 | return { ...acc, [key]: value }; 67 | }, {}); 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["dom", "es2015", "esnext"], 7 | "jsx": "react", 8 | "strict": true, 9 | "isolatedModules": true, 10 | "esModuleInterop": true, 11 | "keyofStringsOnly": true, 12 | "outDir": "dist", 13 | "declaration": true, 14 | "sourceMap": true 15 | }, 16 | "include": ["src"], 17 | "exclude": ["src/**/*.spec.*"] 18 | } 19 | --------------------------------------------------------------------------------