├── .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 |
3 |
4 |
5 | Fielder
6 | A field-first form library for React and React Native.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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 |
6 |
--------------------------------------------------------------------------------
/docs/src/assets/maskable-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/src/assets/nav-button.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------