├── .babelrc.js
├── .eslintignore
├── .eslintrc
├── .flowconfig
├── .github
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── FUNDING.yml
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── ci.yml
│ └── lock.yml
├── .gitignore
├── .prettierrc
├── .travis.yml
├── LICENSE
├── README.md
├── docs
├── chrome.gif
├── firefox.gif
├── html5.png
└── safari.gif
├── eslint.config.js
├── package-scripts.js
├── package.json
├── rollup.config.js
├── src
├── Html5ValidationField.test.tsx
├── Html5ValidationField.tsx
├── index.ts
├── types.ts
└── warning.ts
├── tsconfig.json
└── yarn.lock
/.babelrc.js:
--------------------------------------------------------------------------------
1 | const { NODE_ENV } = process.env
2 | const test = NODE_ENV === 'test'
3 | const loose = true
4 |
5 | module.exports = {
6 | presets: [
7 | ['@babel/preset-env', { modules: false }],
8 | '@babel/preset-react',
9 | '@babel/preset-typescript'
10 | ],
11 | plugins: [
12 | '@babel/plugin-proposal-class-properties',
13 | '@babel/plugin-proposal-decorators',
14 | '@babel/plugin-proposal-export-namespace-from',
15 | '@babel/plugin-proposal-function-sent',
16 | '@babel/plugin-proposal-json-strings',
17 | '@babel/plugin-proposal-numeric-separator',
18 | '@babel/plugin-proposal-throw-expressions',
19 | '@babel/plugin-syntax-dynamic-import',
20 | '@babel/plugin-syntax-import-meta'
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | dist
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["react-app"],
3 | "plugins": ["babel", "react"],
4 | "rules": {
5 | "no-unused-vars": "off",
6 | "@typescript-eslint/no-unused-vars": [
7 | "error",
8 | {
9 | "varsIgnorePattern": "^_",
10 | "argsIgnorePattern": "^_"
11 | }
12 | ],
13 | "jsx-a11y/href-no-hash": 0,
14 | "react/jsx-uses-react": "off",
15 | "react/react-in-jsx-scope": "off"
16 | },
17 | "overrides": [
18 | {
19 | "files": ["**/*.test.ts", "**/*.test.tsx"],
20 | "rules": {
21 | "@typescript-eslint/no-unused-vars": "off"
22 | }
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 |
3 | [include]
4 |
5 | [libs]
6 |
7 | [options]
8 | esproposal.decorators=ignore
9 |
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of
9 | experience, nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or reject
41 | comments, commits, code, wiki edits, issues, and other contributions that are
42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any
43 | contributor for other behaviors that they deem inappropriate, threatening,
44 | offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at rasmussenerik@gmail.com. The project
59 | team will review and investigate all complaints, and will respond in a way that
60 | it deems appropriate to the circumstances. The project team is obligated to
61 | maintain confidentiality with regard to the reporter of an incident. Further
62 | details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
71 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Thanks for your interest in contributing to 🏁 React Final Form HTML5 Validation! Please
4 | take a moment to review this document **before submitting a pull request**.
5 |
6 | We are open to, and grateful for, any contributions made by the community.
7 |
8 | ## Reporting issues and asking questions
9 |
10 | Before opening an issue, please search the
11 | [issue tracker](https://github.com/final-form/react-final-form-html5-validation/issues) to
12 | make sure your issue hasn’t already been reported.
13 |
14 | **We use the issue tracker to keep track of bugs and improvements** to 🏁 React
15 | Final Form HTML5 Validation itself, its examples, and the documentation. We encourage you
16 | to open issues to discuss improvements, architecture, internal implementation,
17 | etc. If a topic has been discussed before, we will ask you to join the previous
18 | discussion.
19 |
20 | For support or usage questions, please search and ask on
21 | [StackOverflow with a `react-final-form-html5-validation` tag](https://stackoverflow.com/questions/tagged/react-final-form-html5-validation).
22 | We ask you to do this because StackOverflow has a much better job at keeping
23 | popular questions visible. Unfortunately good answers get lost and outdated on
24 | GitHub.
25 |
26 | **If you already asked at StackOverflow and still got no answers, post an issue
27 | with the question link, so we can either answer it or evolve into a bug/feature
28 | request.**
29 |
30 | ## Sending a pull request
31 |
32 | **Please ask first before starting work on any significant new features.**
33 |
34 | It's never a fun experience to have your pull request declined after investing a
35 | lot of time and effort into a new feature. To avoid this from happening, we
36 | request that contributors create
37 | [an issue](https://github.com/final-form/react-final-form-html5-validation/issues) to
38 | first discuss any significant new features.
39 |
40 | Please try to keep your pull request focused in scope and avoid including
41 | unrelated commits.
42 |
43 | After you have submitted your pull request, we’ll try to get back to you as soon
44 | as possible. We may suggest some changes or improvements.
45 |
46 | Please format the code before submitting your pull request by running:
47 |
48 | ```
49 | npm run precommit
50 | ```
51 |
52 | ## Coding standards
53 |
54 | Our code formatting rules are defined in
55 | [.eslintrc](https://github.com/final-form/react-final-form-html5-validation/blob/master/.eslintrc).
56 | You can check your code against these standards by running:
57 |
58 | ```sh
59 | npm start lint
60 | ```
61 |
62 | To automatically fix any style violations in your code, you can run:
63 |
64 | ```sh
65 | npm run precommit
66 | ```
67 |
68 | ## Running tests
69 |
70 | You can run the test suite using the following commands:
71 |
72 | ```sh
73 | npm test
74 | ```
75 |
76 | Please ensure that the tests are passing when submitting a pull request. If
77 | you're adding new features to 🏁 React Final Form HTML5 Validation, please include tests.
78 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: erikras
4 | patreon: erikras
5 | open_collective: final-form
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with a single custom sponsorship URL
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | ### Are you submitting a **bug report** or a **feature request**?
8 |
9 |
10 |
11 | ### What is the current behavior?
12 |
13 |
14 |
15 | ### What is the expected behavior?
16 |
17 | ### Sandbox Link
18 |
19 |
20 |
21 | ### What's your environment?
22 |
23 |
24 |
25 | ### Other information
26 |
27 |
28 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
27 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | lint:
7 | name: Lint
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Use Node.js ${{ matrix.node_version }}
13 | uses: actions/setup-node@v2
14 | with:
15 | node-version: "22"
16 | - name: Prepare env
17 | run: yarn install --ignore-scripts --frozen-lockfile
18 | - name: Run linter
19 | run: yarn start lint
20 |
21 | prettier:
22 | name: Prettier Check
23 | runs-on: ubuntu-latest
24 |
25 | steps:
26 | - uses: actions/checkout@v2
27 | - name: Use Node.js ${{ matrix.node_version }}
28 | uses: actions/setup-node@v2
29 | with:
30 | node-version: "22"
31 | - name: Prepare env
32 | run: yarn install --ignore-scripts --frozen-lockfile
33 | - name: Run prettier
34 | run: yarn start prettier
35 |
36 | test:
37 | name: Unit Tests
38 | runs-on: ubuntu-latest
39 |
40 | steps:
41 | - uses: actions/checkout@v2
42 | - name: Use Node.js ${{ matrix.node_version }}
43 | uses: actions/setup-node@v2
44 | with:
45 | node-version: "22"
46 | - name: Prepare env
47 | run: yarn install --ignore-scripts --frozen-lockfile
48 | - name: Run unit tests
49 | run: yarn start test
50 | - name: Run code coverage
51 | uses: codecov/codecov-action@v2.1.0
52 |
--------------------------------------------------------------------------------
/.github/workflows/lock.yml:
--------------------------------------------------------------------------------
1 | name: "Lock Threads"
2 |
3 | on:
4 | schedule:
5 | - cron: "0 * * * *"
6 | workflow_dispatch:
7 |
8 | permissions:
9 | issues: write
10 | pull-requests: write
11 |
12 | concurrency:
13 | group: lock
14 |
15 | jobs:
16 | action:
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: dessant/lock-threads@v3
20 | with:
21 | issue-inactive-days: "365"
22 | issue-lock-reason: "resolved"
23 | pr-inactive-days: "365"
24 | pr-lock-reason: "resolved"
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | *.iml
3 | .nyc_output
4 | coverage
5 | node_modules
6 | dist
7 | lib
8 | es
9 | npm-debug.log
10 | .DS_Store
11 | .idea
12 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | semi: false
2 | singleQuote: true
3 | trailingComma: none
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | before_install:
4 | - npm install -g npm@latest
5 | cache:
6 | directories:
7 | - node_modules
8 | notifications:
9 | email: false
10 | node_js:
11 | - '10'
12 | - '12'
13 | - '14'
14 | script:
15 | - npm start validate
16 | after_success:
17 | - npx codecov
18 | branches:
19 | only:
20 | - master
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Erik Rasmussen
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 | # 🏁 React Final Form HTML5 Validation
4 |
5 | [](https://www.npmjs.com/package/react-final-form-html5-validation)
6 | [](https://www.npmjs.com/package/react-final-form-html5-validation)
7 | [](https://travis-ci.org/final-form/react-final-form-html5-validation)
8 | [](https://codecov.io/gh/final-form/react-final-form-html5-validation)
9 |
10 | ---
11 |
12 | 🏁 React Final Form HTML5 Validation is swappable replacement for [🏁 React Final Form](https://github.com/final-form/react-final-form#-react-final-form)'s `Field` component that provides two-way HTML5 Validation bindings. The bindings are two-way because any [HTML5 contraint validation errors](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api) will be added to the 🏁 Final Form state, and any _field-level_ validation errors from 🏁 Final Form will be set into the HTML5 `validity.customError` state. Unfortunately, this functionality is not compatible with 🏁 React Final Form _record-level_ validation, so the two should not be mixed.
13 |
14 | ### Why not add this functionality directly into the officially bundled `Field` component?
15 |
16 | Good question. The reason is that not everyone needs this functionality, and not everyone is using 🏁 React Final Form with the DOM (e.g. some people use it with React Native). Therefore it makes sense to make this a separate package. This version of `Field` is a thin wrapper over the official `Field` component, and the only `Field` API that this library uses/overrides is the field-level [`validate` prop](https://github.com/final-form/react-final-form#validate-value-any-allvalues-object--any), so even if you are using this library's `Field` component, you will still get improvements as features are added to the 🏁 React Final Form library in the future.
17 |
18 | | Safari | Chrome | Firefox |
19 | | ---------------------------------------- | ---------------------------------------- | ----------------------------------------- |
20 | |
|
|
|
21 |
22 | ## Installation
23 |
24 | ```bash
25 | npm install --save react-final-form-html5-validation react-final-form final-form
26 | ```
27 |
28 | or
29 |
30 | ```bash
31 | yarn add react-final-form-html5-validation react-final-form final-form
32 | ```
33 |
34 | ## [Example](https://codesandbox.io/s/14r018yjp4) 👀
35 |
36 | [](https://codesandbox.io/s/14r018yjp4)
37 |
38 | ## Usage
39 |
40 | The way you specify rules and error messages in HTML5 is by giving first a rule prop, e.g. `required`, `min`, `maxLength`, and then an error message prop, e.g. `valueMissing`, `rangeUnderflow`, or `tooLong`, respectively. This library comes with built-in English defaults for the error messages, but you will probably want to override those by passing in your own.
41 |
42 | ```jsx
43 | import { Form } from 'react-final-form'
44 | import { Field } from 'react-final-form-html5-validation'
45 |
46 | const MyForm = () => (
47 |
66 | )}
67 | />
68 | )
69 | ```
70 |
71 | ## Table of Contents
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | - [Rules and Messages](#rules-and-messages)
80 | - [API](#api)
81 | - [Rules](#rules)
82 | - [`max?: Number`](#max-number)
83 | - [`maxLength?: Number`](#maxlength-number)
84 | - [`min?: Number`](#min-number)
85 | - [`minLength?: Number`](#minlength-number)
86 | - [`pattern?: string`](#pattern-string)
87 | - [`required?: boolean`](#required-boolean)
88 | - [`step?: Number`](#step-number)
89 | - [Messages](#messages)
90 | - [`badInput?: string`](#badinput-string)
91 | - [`patternMismatch?: string`](#patternmismatch-string)
92 | - [`rangeOverflow?: string`](#rangeoverflow-string)
93 | - [`rangeUnderflow?: string`](#rangeunderflow-string)
94 | - [`stepMismatch?: string`](#stepmismatch-string)
95 | - [`tooLong?: string`](#toolong-string)
96 | - [`tooShort?: string`](#tooshort-string)
97 | - [`typeMismatch?: string`](#typemismatch-string)
98 | - [`valueMissing?: string`](#valuemissing-string)
99 | - [Internationalization](#internationalization)
100 |
101 |
102 |
103 | ## Rules and Messages
104 |
105 | These all come from the [HTML Standard](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api).
106 |
107 | | Rule | Value | Message | Meaning |
108 | | -------------------------------------------------------------------------------------------------------- | --------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
109 | | | | `badInput` | [The value is invalid somehow](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-bad-input) |
110 | | [`max`](https://html.spec.whatwg.org/multipage/input.html#attr-input-max) | `Number` | `rangeOverflow` | [The value is too high](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-an-overflow) |
111 | | [`maxLength`](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fe-maxlength) | `Number` | `tooLong` | [The value is too long](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-being-too-long) |
112 | | [`min`](https://html.spec.whatwg.org/multipage/input.html#attr-input-min) | `Number` | `rangeUnderflow` | [The value is too small](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-an-underflow) |
113 | | [`minLength`](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fe-minlength) | `Number` | `tooShort` | [The value is too short](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-being-too-short) |
114 | | [`pattern`](https://html.spec.whatwg.org/multipage/input.html#attr-input-pattern) | `string` | `patternMismatch` | [The value does not match the regular expression](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-a-pattern-mismatch) |
115 | | [`required`](https://html.spec.whatwg.org/multipage/input.html#the-required-attribute) | `boolean` | `valueMissing` | [The value is missing](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-being-missing) |
116 | | [`step`](https://html.spec.whatwg.org/multipage/input.html#attr-input-step) | `Number` | `stepMismatch` | [The value does not match the step granularity](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-a-step-mismatch) |
117 | | | | `typeMismatch` | [The value does not match the specified `type`](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-a-type-mismatch) |
118 |
119 | ## API
120 |
121 | In addition to all the [`FieldProps`](https://github.com/final-form/react-final-form#fieldprops) that you can pass to the standard `Field`, to an HTML5 Validation `Field`, you may also pass:
122 |
123 | ### Rules
124 |
125 | #### `max?: Number`
126 |
127 | The maximum value allowed as the value for the input. If invalid, the `rangeOverflow` error will be displayed.
128 |
129 | #### `maxLength?: Number`
130 |
131 | The maximum length allowed of the input value. If invalid, the `tooLong` error will be displayed.
132 |
133 | #### `min?: Number`
134 |
135 | The minimum value allowed as the value for the input. If invalid, the `rangeUnderflow` error will be displayed.
136 |
137 | #### `minLength?: Number`
138 |
139 | The minimum length allowed of the input value. If invalid, the `tooShort` error will be displayed.
140 |
141 | #### `pattern?: string`
142 |
143 | A string regular expression to test the input value against. If invalid, the `patternMismatch` error will be displayed.
144 |
145 | #### `required?: boolean`
146 |
147 | Whether or not the field is required. If invalid, the `valueMissing` error will be displayed.
148 |
149 | #### `step?: Number`
150 |
151 | The step size between the `min` and `max` values. If invalid, the `stepMismatch` error will be displayed.
152 |
153 | ### Messages
154 |
155 | #### `badInput?: string`
156 |
157 | The message to display [when the input is invalid somehow](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-bad-input).
158 |
159 | #### `patternMismatch?: string|(value?: any, props: Object) => string`
160 |
161 | The message to display when the value does not match the pattern specified by the `pattern` prop.
162 |
163 | #### `rangeOverflow?: string|(value?: any, props: Object) => string`
164 |
165 | The message to display when the value is higher than the `max` prop.
166 |
167 | #### `rangeUnderflow?: string|(value?: any, props: Object) => string`
168 |
169 | The message to display when the value is lower than the `min` prop.
170 |
171 | #### `stepMismatch?: string|(value?: any, props: Object) => string`
172 |
173 | The message to display the value is not one of the valid steps specified by the `step` prop.
174 |
175 | #### `tooLong?: string|(value?: any, props: Object) => string`
176 |
177 | The message to display when the value longer than the value specified by the `maxLength` prop.
178 |
179 | #### `tooShort?: string|(value?: any, props: Object) => string`
180 |
181 | The message to display when the value shorter than the value specified by the `minLength` prop.
182 |
183 | #### `typeMismatch?: string|(value?: any, props: Object) => string`
184 |
185 | The message to display when the value does not match the `type` prop.
186 |
187 | #### `valueMissing?: string|(value?: any, props: Object) => string`
188 |
189 | The message to display when the value is required, but missing.
190 |
191 | ## Internationalization
192 |
193 | If internationalization is important to your project, you should probably create a component that wraps this component, pulls the localized messages from the context, and renders:
194 |
195 | ```jsx
196 |
197 | ```
198 |
--------------------------------------------------------------------------------
/docs/chrome.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/final-form/react-final-form-html5-validation/35035e02479e47f1743eb60836b28104af3e74e5/docs/chrome.gif
--------------------------------------------------------------------------------
/docs/firefox.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/final-form/react-final-form-html5-validation/35035e02479e47f1743eb60836b28104af3e74e5/docs/firefox.gif
--------------------------------------------------------------------------------
/docs/html5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/final-form/react-final-form-html5-validation/35035e02479e47f1743eb60836b28104af3e74e5/docs/html5.png
--------------------------------------------------------------------------------
/docs/safari.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/final-form/react-final-form-html5-validation/35035e02479e47f1743eb60836b28104af3e74e5/docs/safari.gif
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import parser from '@typescript-eslint/parser'
2 | import plugin from '@typescript-eslint/eslint-plugin'
3 |
4 | export default [
5 | {
6 | ignores: [
7 | 'dist/**',
8 | 'node_modules/**',
9 | 'coverage/**',
10 | '*.config.js',
11 | '*.config.cjs',
12 | '*.config.mjs',
13 | 'package-scripts.js',
14 | 'package-scripts.cjs',
15 | '.babelrc.js',
16 | 'src/**/*.test.tsx'
17 | ]
18 | },
19 | {
20 | files: ['src/**/*.{js,ts,tsx}', 'tests/**/*.{js,ts,tsx}'],
21 | languageOptions: {
22 | ecmaVersion: 'latest',
23 | sourceType: 'module',
24 | parser,
25 | parserOptions: {
26 | project: './tsconfig.json'
27 | }
28 | },
29 | plugins: {
30 | '@typescript-eslint': plugin
31 | },
32 | rules: {
33 | '@typescript-eslint/no-explicit-any': 'warn',
34 | '@typescript-eslint/explicit-function-return-type': 'off',
35 | '@typescript-eslint/explicit-module-boundary-types': 'off'
36 | }
37 | }
38 | ]
39 |
--------------------------------------------------------------------------------
/package-scripts.js:
--------------------------------------------------------------------------------
1 | import npsUtils from 'nps-utils'
2 |
3 | const { series, concurrent, rimraf, crossEnv } = npsUtils
4 |
5 | export default {
6 | scripts: {
7 | test: {
8 | default: crossEnv('NODE_ENV=test jest --coverage'),
9 | update: crossEnv('NODE_ENV=test jest --coverage --updateSnapshot'),
10 | watch: crossEnv('NODE_ENV=test jest --watch'),
11 | codeCov: crossEnv(
12 | 'cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js'
13 | ),
14 | size: {
15 | description: 'check the size of the bundle',
16 | script: 'bundlesize'
17 | }
18 | },
19 | build: {
20 | description: 'delete the dist directory and run all builds',
21 | default: series(
22 | rimraf('dist'),
23 | concurrent.nps(
24 | 'build.es',
25 | 'build.cjs',
26 | 'build.umd.main',
27 | 'build.umd.min',
28 | 'copyTypes'
29 | )
30 | ),
31 | es: {
32 | description: 'run the build with rollup (uses rollup.config.js)',
33 | script: 'rollup --config --environment FORMAT:es'
34 | },
35 | cjs: {
36 | description: 'run rollup build with CommonJS format',
37 | script: 'rollup --config --environment FORMAT:cjs'
38 | },
39 | umd: {
40 | min: {
41 | description: 'run the rollup build with sourcemaps',
42 | script: 'rollup --config --sourcemap --environment MINIFY,FORMAT:umd'
43 | },
44 | main: {
45 | description: 'builds the cjs and umd files',
46 | script: 'rollup --config --sourcemap --environment FORMAT:umd'
47 | }
48 | },
49 | andTest: series.nps('build', 'test.size')
50 | },
51 | copyTypes: series(
52 | rimraf('dist/*.d.ts'),
53 | 'tsc --emitDeclarationOnly --outDir dist'
54 | ),
55 | docs: {
56 | description: 'Generates table of contents in README',
57 | script: 'doctoc README.md'
58 | },
59 | lint: {
60 | description: 'lint the entire project',
61 | script: 'eslint .'
62 | },
63 | prettier: {
64 | description: 'Runs prettier on everything',
65 | script: 'prettier --write "**/*.([jt]s*)"'
66 | },
67 | validate: {
68 | description:
69 | 'This runs several scripts to make sure things look good before committing or on clean install',
70 | default: concurrent.nps('lint', 'build.andTest', 'test')
71 | }
72 | },
73 | options: {
74 | silent: false
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-final-form-html5-validation",
3 | "version": "2.0.0-0",
4 | "description": "A swap-in replacement for 🏁 React Final Form's component to provide HTML5 Validation",
5 | "type": "module",
6 | "main": "dist/react-final-form-html5-validation.cjs.js",
7 | "jsnext:main": "dist/react-final-form-html5-validation.es.js",
8 | "module": "dist/react-final-form-html5-validation.es.js",
9 | "types": "dist/index.d.ts",
10 | "sideEffects": false,
11 | "files": [
12 | "dist"
13 | ],
14 | "scripts": {
15 | "start": "nps",
16 | "test": "nps test",
17 | "precommit": "lint-staged && npm start validate",
18 | "prepare": "nps build",
19 | "build": "node node_modules/nps-utils/node_modules/rimraf/bin.js dist && node node_modules/concurrently/src/main.js --kill-others-on-fail --prefix-colors \"bgBlue.bold,bgMagenta.bold,bgGreen.bold,bgBlack.bold,bgCyan.bold\" --prefix \"[{name}]\" --names \"build.es,build.cjs,build.umd.main,build.umd.min\" 'nps build.es' 'nps build.cjs' 'nps build.umd.main' 'nps build.umd.min'",
20 | "clean": "rimraf dist",
21 | "validate": "npm run clean && npm run build"
22 | },
23 | "author": "Erik Rasmussen (http://github.com/erikras)",
24 | "license": "MIT",
25 | "repository": {
26 | "type": "git",
27 | "url": "https://github.com/final-form/react-final-form-html5-validation.git"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/final-form/react-final-form-html5-validation/issues"
31 | },
32 | "homepage": "https://github.com/final-form/react-final-form-html5-validation#readme",
33 | "devDependencies": {
34 | "@babel/core": "^7.27.4",
35 | "@babel/plugin-proposal-class-properties": "^7.18.6",
36 | "@babel/plugin-proposal-decorators": "^7.27.1",
37 | "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
38 | "@babel/plugin-proposal-function-sent": "^7.27.1",
39 | "@babel/plugin-proposal-json-strings": "^7.18.6",
40 | "@babel/plugin-proposal-numeric-separator": "^7.18.6",
41 | "@babel/plugin-proposal-throw-expressions": "^7.27.1",
42 | "@babel/plugin-syntax-dynamic-import": "^7.8.3",
43 | "@babel/plugin-syntax-import-meta": "^7.10.4",
44 | "@babel/preset-env": "^7.27.2",
45 | "@babel/preset-react": "^7.27.1",
46 | "@babel/preset-typescript": "^7.27.1",
47 | "@testing-library/dom": "^10.4.0",
48 | "@testing-library/react": "^16.3.0",
49 | "@types/jest": "^29.5.12",
50 | "@types/node": "^20.11.24",
51 | "@types/prop-types": "^15.7.14",
52 | "@types/react": "^18.3.23",
53 | "@types/react-dom": "^18.3.7",
54 | "babel-core": "^7.0.0-bridge.0",
55 | "babel-eslint": "^10.1.0",
56 | "babel-jest": "^29.7.0",
57 | "bundlesize": "^0.18.2",
58 | "doctoc": "^2.2.1",
59 | "eslint": "^9.27.0",
60 | "eslint-config-react-app": "^7.0.1",
61 | "eslint-plugin-babel": "^5.3.1",
62 | "eslint-plugin-import": "^2.31.0",
63 | "eslint-plugin-jsx-a11y": "^6.10.2",
64 | "eslint-plugin-react": "^7.37.5",
65 | "eslint-plugin-react-hooks": "^5.2.0",
66 | "final-form": "^5.0.0-3",
67 | "husky": "^9.1.7",
68 | "jest": "^29.7.0",
69 | "jest-environment-jsdom": "^30.0.0-beta.3",
70 | "lint-staged": "^16.1.0",
71 | "nps": "^5.10.0",
72 | "nps-utils": "^1.7.0",
73 | "prettier": "^3.5.3",
74 | "prettier-eslint-cli": "^8.0.1",
75 | "prop-types": "^15.8.1",
76 | "raf": "^3.4.1",
77 | "react": "^18.2.0",
78 | "react-dom": "^18.2.0",
79 | "react-final-form": "^7.0.0-0",
80 | "react-test-renderer": "^18.2.0",
81 | "rimraf": "^6.0.1",
82 | "rollup": "^4.41.1",
83 | "rollup-plugin-babel": "^4.4.0",
84 | "rollup-plugin-commonjs": "^10.1.0",
85 | "rollup-plugin-node-resolve": "^5.2.0",
86 | "rollup-plugin-replace": "^2.2.0",
87 | "rollup-plugin-typescript2": "^0.36.0",
88 | "rollup-plugin-uglify": "^6.0.4",
89 | "ts-jest": "^29.3.4",
90 | "typescript": "^5.3.3"
91 | },
92 | "peerDependencies": {
93 | "final-form": ">=5.0.0-3",
94 | "prop-types": "^15.6.0",
95 | "react": "^18.2.0",
96 | "react-dom": "^18.2.0",
97 | "react-final-form": ">=7.0.0-0"
98 | },
99 | "jest": {
100 | "testEnvironment": "jsdom",
101 | "transform": {
102 | "^.+\\.(ts|tsx)$": "ts-jest"
103 | }
104 | },
105 | "lint-staged": {
106 | "*.{js*,ts*,json,md,css}": [
107 | "prettier --write",
108 | "git add"
109 | ]
110 | },
111 | "bundlesize": [
112 | {
113 | "path": "dist/react-final-form-html5-validation.umd.min.js",
114 | "maxSize": "2 kB"
115 | },
116 | {
117 | "path": "dist/react-final-form-html5-validation.es.js",
118 | "maxSize": "3 kB"
119 | },
120 | {
121 | "path": "dist/react-final-form-html5-validation.cjs.js",
122 | "maxSize": "3 kB"
123 | }
124 | ],
125 | "collective": {
126 | "type": "opencollective",
127 | "url": "https://opencollective.com/final-form"
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from 'rollup-plugin-node-resolve'
2 | import babel from 'rollup-plugin-babel'
3 | import commonjs from 'rollup-plugin-commonjs'
4 | import { uglify } from 'rollup-plugin-uglify'
5 | import replace from 'rollup-plugin-replace'
6 | import typescript from 'rollup-plugin-typescript2'
7 |
8 | const minify = process.env.MINIFY
9 | const format = process.env.FORMAT
10 | const es = format === 'es'
11 | const umd = format === 'umd'
12 | const cjs = format === 'cjs'
13 |
14 | let output
15 |
16 | if (es) {
17 | output = {
18 | file: `dist/react-final-form-html5-validation.es.js`,
19 | format: 'es'
20 | }
21 | } else if (umd) {
22 | if (minify) {
23 | output = {
24 | file: `dist/react-final-form-html5-validation.umd.min.js`,
25 | format: 'umd'
26 | }
27 | } else {
28 | output = {
29 | file: `dist/react-final-form-html5-validation.umd.js`,
30 | format: 'umd'
31 | }
32 | }
33 | } else if (cjs) {
34 | output = {
35 | file: `dist/react-final-form-html5-validation.cjs.js`,
36 | format: 'cjs'
37 | }
38 | } else if (format) {
39 | throw new Error(`invalid format specified: "${format}".`)
40 | } else {
41 | throw new Error('no format specified. --environment FORMAT:xxx')
42 | }
43 |
44 | export default {
45 | input: 'src/index.ts',
46 | output: Object.assign(
47 | {
48 | name: 'react-final-form-html5-validation',
49 | exports: 'named',
50 | globals: {
51 | react: 'React',
52 | 'react-dom': 'ReactDOM',
53 | 'prop-types': 'PropTypes',
54 | 'final-form': 'FinalForm',
55 | 'react-final-form': 'ReactFinalForm'
56 | }
57 | },
58 | output
59 | ),
60 | external: (id) =>
61 | ['react', 'react-dom', 'prop-types', 'final-form', 'react-final-form'].some(
62 | (pkg) => id === pkg || id.startsWith(pkg + '/')
63 | ),
64 | plugins: [
65 | resolve({ jsnext: true, main: true }),
66 | typescript({
67 | tsconfig: './tsconfig.json',
68 | useTsconfigDeclarationDir: true
69 | }),
70 | commonjs({ include: 'node_modules/**' }),
71 | // babel({
72 | // exclude: 'node_modules/**',
73 | // babelrc: false,
74 | // presets: [['env', { modules: false }], 'stage-2'],
75 | // plugins: ['external-helpers']
76 | // }),
77 | umd
78 | ? replace({
79 | 'process.env.NODE_ENV': JSON.stringify(
80 | minify ? 'production' : 'development'
81 | )
82 | })
83 | : null,
84 | minify ? uglify() : null
85 | ].filter(Boolean)
86 | }
87 |
--------------------------------------------------------------------------------
/src/Html5ValidationField.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { render, cleanup } from '@testing-library/react'
4 | import { Form, FieldRenderProps, FieldInputProps } from 'react-final-form'
5 | import Html5ValidationField, {
6 | Html5ValidationField as Html5ValidationFieldClass
7 | } from './Html5ValidationField'
8 |
9 | const onSubmitMock = () => {}
10 |
11 | const getAttributes = (el: HTMLElement): Record => {
12 | const attributes: Record = {}
13 | for (let i = 0; i < el.attributes.length; i++) {
14 | const attr = el.attributes[i]
15 | attributes[attr.name] = attr.value
16 | }
17 | return attributes
18 | }
19 |
20 | interface TestFieldRenderProps
21 | extends FieldRenderProps {
22 | input: FieldInputProps
23 | }
24 |
25 | describe('Html5ValidationField', () => {
26 | afterEach(() => {
27 | cleanup()
28 | jest.clearAllMocks()
29 | })
30 |
31 | describe('Html5ValidationField.rules', () => {
32 | const testPassThrough = (
33 | rule: Record,
34 | testId = 'input'
35 | ) => {
36 | const consoleSpy = jest
37 | .spyOn(global.console, 'error')
38 | .mockImplementation(() => {})
39 | const { getByTestId } = render(
40 |
50 | )
51 | const input = getByTestId(testId)
52 | expect(input).toBeDefined()
53 | Object.keys(rule).forEach((key) => {
54 | if (key === 'required') {
55 | expect(input.hasAttribute('required')).toBe(true)
56 | } else if (key === 'minLength') {
57 | expect(input.getAttribute('minlength')).toBe(rule[key]?.toString())
58 | } else if (key === 'maxLength') {
59 | expect(input.getAttribute('maxlength')).toBe(rule[key]?.toString())
60 | } else {
61 | expect(input.getAttribute(key)).toBe(rule[key]?.toString())
62 | }
63 | })
64 | consoleSpy.mockRestore()
65 | }
66 |
67 | it('should pass "required" through to input', () => {
68 | testPassThrough({ required: true })
69 | })
70 |
71 | it('should pass "pattern" through to input', () => {
72 | testPassThrough({ pattern: 'text' }, 'text')
73 | testPassThrough({ pattern: 'search' }, 'search')
74 | testPassThrough({ pattern: 'url' }, 'url')
75 | testPassThrough({ pattern: 'tel' }, 'tel')
76 | testPassThrough({ pattern: 'email' }, 'email')
77 | testPassThrough({ pattern: 'password' }, 'password')
78 | testPassThrough({ pattern: /look, ma, a regex!/ }, 'regex')
79 | })
80 |
81 | it('should pass "min" through to input', () => {
82 | testPassThrough({ min: 2 })
83 | })
84 |
85 | it('should pass "max" through to input', () => {
86 | testPassThrough({ max: 5 })
87 | })
88 |
89 | it('should pass "step" through to input', () => {
90 | testPassThrough({ step: 3 })
91 | })
92 |
93 | it('should pass "minlength" through to input', () => {
94 | testPassThrough({ minLength: 5 })
95 | })
96 |
97 | it('should pass "maxlength" through to input', () => {
98 | testPassThrough({ maxLength: 8 })
99 | })
100 | })
101 |
102 | it('should pass ref through to the input', () => {
103 | const ref = React.createRef()
104 | render(
105 |
110 | )}
111 |
112 | )
113 |
114 | expect(ref.current).not.toBe(null)
115 | expect(ref.current).toBeInstanceOf(HTMLInputElement)
116 | // Optionally, check that input is set up (if componentDidMount runs in test env)
117 | // expect(ref.current.input).not.toBe(null)
118 | })
119 |
120 | describe('Html5ValidationField.messages', () => {
121 | const testNotPassThrough = (message: Record) => {
122 | const consoleSpy = jest
123 | .spyOn(global.console, 'error')
124 | .mockImplementation(() => {})
125 | const { getByTestId } = render(
126 |
136 | )
137 | const input = getByTestId('input')
138 | expect(input).toBeDefined()
139 | const attributes = getAttributes(input)
140 | Object.keys(message).forEach((key) =>
141 | expect(attributes[key]).toBeUndefined()
142 | )
143 | consoleSpy.mockRestore()
144 | }
145 | ;[
146 | 'badInput',
147 | 'patternMismatch',
148 | 'rangeOverflow',
149 | 'rangeUnderflow',
150 | 'stepMismatch',
151 | 'tooLong',
152 | 'tooShort',
153 | 'typeMismatch',
154 | 'valueMissing'
155 | ].forEach((key) => {
156 | it(`should not pass "${key}" through to input`, () => {
157 | testNotPassThrough({ [key]: 'All your base are belong to us' })
158 | })
159 | })
160 | })
161 |
162 | describe('Html5ValidationField.validity', () => {
163 | let findDOMNodeSpy: jest.SpyInstance
164 | afterEach(() => {
165 | if (findDOMNodeSpy) {
166 | findDOMNodeSpy.mockRestore()
167 | }
168 | })
169 | const mockFindNode = (querySelector: jest.Mock) => {
170 | const div = document.createElement('div')
171 | div.querySelector = querySelector
172 | findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div)
173 | return div
174 | }
175 |
176 | it('should use the root node if it is an input element', () => {
177 | const input = document.createElement('input')
178 | input.name = 'foo'
179 | input.setCustomValidity = jest.fn()
180 | Object.defineProperty(input, 'validity', {
181 | value: { valid: true } as ValidityState,
182 | configurable: true
183 | })
184 | findDOMNodeSpy = jest
185 | .spyOn(ReactDOM, 'findDOMNode')
186 | .mockReturnValue(input)
187 | render(
188 |
195 | )
196 | expect(input.setCustomValidity).toHaveBeenCalled()
197 | })
198 |
199 | it('should search DOM for input if the root is not the input', () => {
200 | const input = document.createElement('input')
201 | input.name = 'foo'
202 | const querySelector = jest.fn().mockReturnValue(input)
203 | const div = mockFindNode(querySelector)
204 | render(
205 |
214 | )
215 | expect(querySelector).toHaveBeenCalled()
216 | expect(querySelector).toHaveBeenCalledTimes(1)
217 | const calls = querySelector.mock.calls
218 | if (calls.length > 0) {
219 | expect(calls[0][0]).toBe(
220 | 'input[name="foo"],textarea[name="foo"],select[name="foo"]'
221 | )
222 | }
223 | })
224 |
225 | it('should search DOM for input if the root is not the input, even for deep fields', () => {
226 | const input = document.createElement('input')
227 | input.name = 'foo.bar'
228 | const querySelector = jest.fn().mockReturnValue(input)
229 | const div = mockFindNode(querySelector)
230 | render(
231 |
240 | )
241 | expect(querySelector).toHaveBeenCalled()
242 | expect(querySelector).toHaveBeenCalledTimes(1)
243 | const calls = querySelector.mock.calls
244 | if (calls.length > 0) {
245 | expect(calls[0][0]).toBe(
246 | 'input[name="foo.bar"],textarea[name="foo.bar"],select[name="foo.bar"]'
247 | )
248 | }
249 | })
250 |
251 | it('should fail silently if no DOM node could be found (probably SSR)', () => {
252 | const consoleSpy = jest
253 | .spyOn(global.console, 'error')
254 | .mockImplementation(() => {})
255 | findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(null)
256 | render(
257 |
264 | )
265 | expect(consoleSpy).not.toHaveBeenCalled()
266 | consoleSpy.mockRestore()
267 | })
268 |
269 | it('should warn if no input could be found because DOM node has no querySelector API', () => {
270 | const consoleSpy = jest
271 | .spyOn(global.console, 'error')
272 | .mockImplementation(() => {})
273 | const div = document.createElement('div')
274 | Object.defineProperty(div, 'querySelector', {
275 | value: undefined,
276 | configurable: true
277 | })
278 | render(
279 |
288 | )
289 | expect(consoleSpy).toHaveBeenCalled()
290 | expect(consoleSpy).toHaveBeenCalledTimes(1)
291 | expect(consoleSpy).toHaveBeenCalledWith(
292 | 'Warning: Could not find DOM input with HTML validity API'
293 | )
294 | consoleSpy.mockRestore()
295 | })
296 |
297 | it('should warn if no input could be found', () => {
298 | const consoleSpy = jest
299 | .spyOn(global.console, 'error')
300 | .mockImplementation(() => {})
301 | const querySelector = jest.fn(() => null)
302 | const div = mockFindNode(querySelector)
303 | render(
304 |
313 | )
314 | expect(querySelector).toHaveBeenCalled()
315 | expect(querySelector).toHaveBeenCalledTimes(1)
316 | expect(querySelector).toHaveBeenCalledWith(
317 | 'input[name="foo"],textarea[name="foo"],select[name="foo"]'
318 | )
319 | expect(consoleSpy).toHaveBeenCalled()
320 | expect(consoleSpy).toHaveBeenCalledTimes(1)
321 | expect(consoleSpy).toHaveBeenCalledWith(
322 | 'Warning: Could not find DOM input with HTML validity API'
323 | )
324 | consoleSpy.mockRestore()
325 | })
326 |
327 | it('should read/write validity from/to the input', () => {
328 | const setCustomValidity = jest.fn()
329 | const input = document.createElement('input')
330 | input.name = 'foo'
331 | input.setCustomValidity = setCustomValidity
332 | Object.defineProperty(input, 'validity', {
333 | value: {
334 | valueMissing: true,
335 | valid: false
336 | } as ValidityState,
337 | configurable: true
338 | })
339 | const querySelector = jest.fn().mockReturnValue(input)
340 | const div = mockFindNode(querySelector)
341 | render(
342 |
351 | )
352 | expect(setCustomValidity).toHaveBeenCalled()
353 | expect(setCustomValidity).toHaveBeenCalledTimes(2)
354 | expect(setCustomValidity.mock.calls[0][0]).toBe('')
355 | expect(setCustomValidity.mock.calls[1][0]).toBe('Required')
356 | })
357 |
358 | it('should use field-level validation function', () => {
359 | const validate = jest.fn().mockReturnValue('bar')
360 | const setCustomValidity = jest.fn()
361 | const input = document.createElement('input')
362 | input.name = 'foo'
363 | input.setCustomValidity = setCustomValidity
364 | Object.defineProperty(input, 'validity', {
365 | value: { valid: true } as ValidityState,
366 | configurable: true
367 | })
368 | const querySelector = jest.fn().mockReturnValue(input)
369 | const div = document.createElement('div')
370 | div.querySelector = querySelector
371 | findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div)
372 | render(
373 |
386 | )
387 | expect(validate).toHaveBeenCalled()
388 | expect(validate).toHaveBeenCalledTimes(1)
389 | expect(validate.mock.calls[0][0]).toBe('test')
390 | })
391 |
392 | it('should not call setCustomValidity if no validity API is found', () => {
393 | const consoleSpy = jest
394 | .spyOn(global.console, 'error')
395 | .mockImplementation(() => {})
396 | const querySelector = jest.fn(() => null)
397 | const div = mockFindNode(querySelector)
398 | render(
399 |
408 | )
409 | expect(consoleSpy).toHaveBeenCalled()
410 | expect(consoleSpy).toHaveBeenCalledTimes(1)
411 | expect(consoleSpy).toHaveBeenCalledWith(
412 | 'Warning: Could not find DOM input with HTML validity API'
413 | )
414 | consoleSpy.mockRestore()
415 | })
416 |
417 | it('should not call setCustomValidity if validation error is not a string', () => {
418 | const consoleSpy = jest
419 | .spyOn(global.console, 'error')
420 | .mockImplementation(() => {})
421 | const setCustomValidity = jest.fn()
422 | const input = document.createElement('input')
423 | input.name = 'foo'
424 | input.setCustomValidity = setCustomValidity
425 | Object.defineProperty(input, 'validity', {
426 | value: {
427 | valid: false,
428 | valueMissing: false
429 | } as ValidityState,
430 | configurable: true
431 | })
432 | const querySelector = jest.fn().mockReturnValue(input)
433 | const div = document.createElement('div')
434 | div.querySelector = querySelector
435 | findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div)
436 | const validate = () => ({ notAString: true })
437 | render(
438 |
451 | )
452 | expect(consoleSpy).not.toHaveBeenCalled()
453 | setCustomValidity.mock.calls.forEach((call) => {
454 | expect(call[0]).toBe('')
455 | })
456 | consoleSpy.mockRestore()
457 | })
458 |
459 | it('should not call setCustomValidity if no validation error', () => {
460 | const consoleSpy = jest
461 | .spyOn(global.console, 'error')
462 | .mockImplementation(() => {})
463 | const setCustomValidity = jest.fn()
464 | const input = document.createElement('input')
465 | input.name = 'foo'
466 | input.setCustomValidity = setCustomValidity
467 | Object.defineProperty(input, 'validity', {
468 | value: {
469 | valid: true
470 | } as ValidityState,
471 | configurable: true
472 | })
473 | const querySelector = jest.fn().mockReturnValue(input)
474 | const div = mockFindNode(querySelector)
475 | render(
476 |
485 | )
486 | expect(consoleSpy).not.toHaveBeenCalled()
487 | expect(setCustomValidity).toHaveBeenCalled()
488 | expect(setCustomValidity).toHaveBeenCalledTimes(1)
489 | expect(setCustomValidity.mock.calls[0][0]).toBe('')
490 | consoleSpy.mockRestore()
491 | })
492 |
493 | it('should not call setCustomValidity if valid === true', () => {
494 | const consoleSpy = jest
495 | .spyOn(global.console, 'error')
496 | .mockImplementation(() => {})
497 | const setCustomValidity = jest.fn()
498 | const input = document.createElement('input')
499 | input.name = 'foo'
500 | input.setCustomValidity = setCustomValidity
501 | Object.defineProperty(input, 'validity', {
502 | value: {
503 | valid: true
504 | } as ValidityState,
505 | configurable: true
506 | })
507 | const querySelector = jest.fn().mockReturnValue(input)
508 | const div = mockFindNode(querySelector)
509 | render(
510 |
519 | )
520 | expect(consoleSpy).not.toHaveBeenCalled()
521 | expect(setCustomValidity).toHaveBeenCalled()
522 | expect(setCustomValidity).toHaveBeenCalledTimes(1)
523 | expect(setCustomValidity.mock.calls[0][0]).toBe('')
524 | consoleSpy.mockRestore()
525 | })
526 |
527 | it('should report back validity custom error to Final Form', () => {
528 | const consoleSpy = jest
529 | .spyOn(global.console, 'error')
530 | .mockImplementation(() => {})
531 | const setCustomValidity = jest.fn()
532 | const input = document.createElement('input')
533 | input.name = 'foo'
534 | input.setCustomValidity = setCustomValidity
535 | Object.defineProperty(input, 'validity', {
536 | value: {
537 | valid: false,
538 | customError: true
539 | } as ValidityState,
540 | configurable: true
541 | })
542 | Object.defineProperty(input, 'validationMessage', {
543 | value: 'Ooh, how custom!',
544 | configurable: true
545 | })
546 | const querySelector = jest.fn().mockReturnValue(input)
547 | const div = mockFindNode(querySelector)
548 | render(
549 |
558 | )
559 | expect(consoleSpy).not.toHaveBeenCalled()
560 | expect(setCustomValidity).toHaveBeenCalled()
561 | expect(setCustomValidity).toHaveBeenCalledTimes(1)
562 | expect(setCustomValidity.mock.calls[0][0]).toBe('')
563 | consoleSpy.mockRestore()
564 | })
565 |
566 | it('should support functions as default error keys', () => {
567 | const setCustomValidity = jest.fn()
568 | const input = document.createElement('input')
569 | input.name = 'foo'
570 | input.setCustomValidity = setCustomValidity
571 | Object.defineProperty(input, 'validity', {
572 | value: {
573 | tooShort: true,
574 | valid: false
575 | } as ValidityState,
576 | configurable: true
577 | })
578 | const querySelector = jest.fn().mockReturnValue(input)
579 | const div = document.createElement('div')
580 | div.querySelector = querySelector
581 | findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div)
582 | render(
583 |
602 | )
603 | expect(setCustomValidity).toHaveBeenCalledTimes(2)
604 | expect(setCustomValidity.mock.calls[0][0]).toBe('')
605 | expect(setCustomValidity.mock.calls[1][0]).toBe(
606 | 'Value bar should have at least 8 characters.'
607 | )
608 | })
609 |
610 | it('should warn if the root node is not an input and has no querySelector API', () => {
611 | const consoleSpy = jest
612 | .spyOn(global.console, 'error')
613 | .mockImplementation(() => {})
614 | const root = document.createElement('div')
615 | Object.defineProperty(root, 'querySelector', {
616 | value: undefined,
617 | configurable: true
618 | })
619 | const findDOMNodeSpy = jest
620 | .spyOn(ReactDOM, 'findDOMNode')
621 | .mockReturnValue(root)
622 | render(
623 |
630 | )
631 | expect(consoleSpy).toHaveBeenCalledWith(
632 | 'Warning: Could not find DOM input with HTML validity API'
633 | )
634 | consoleSpy.mockRestore()
635 | findDOMNodeSpy.mockRestore()
636 | })
637 |
638 | it('should use validate prop when no input element is found', async () => {
639 | const validate = jest.fn().mockReturnValue('Validation error')
640 | const consoleSpy = jest
641 | .spyOn(global.console, 'error')
642 | .mockImplementation(() => {})
643 | findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(null)
644 | render(
645 |
652 | )
653 | // Wait for component to mount and validate
654 | await new Promise((resolve) => setTimeout(resolve, 0))
655 | expect(consoleSpy).toHaveBeenCalledWith(
656 | 'Warning: Could not find DOM input with HTML validity API'
657 | )
658 | expect(validate).toHaveBeenCalled()
659 | consoleSpy.mockRestore()
660 | findDOMNodeSpy.mockRestore()
661 | })
662 | })
663 | })
664 |
--------------------------------------------------------------------------------
/src/Html5ValidationField.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { Field } from 'react-final-form'
4 | import { Html5ValidationFieldProps } from './types'
5 | import warning from './warning'
6 |
7 | const errorKeys: string[] = [
8 | 'badInput',
9 | 'patternMismatch',
10 | 'rangeOverflow',
11 | 'rangeUnderflow',
12 | 'stepMismatch',
13 | 'tooLong',
14 | 'tooShort',
15 | 'typeMismatch',
16 | 'valueMissing'
17 | ]
18 |
19 | interface WithValidity {
20 | validity: ValidityState
21 | setCustomValidity: (error: string | null) => void
22 | validationMessage: string
23 | }
24 |
25 | class Html5ValidationField extends React.Component {
26 | private input: WithValidity | null = null
27 |
28 | static defaultProps = {
29 | badInput: 'Incorrect input',
30 | patternMismatch: 'Does not match expected pattern',
31 | rangeOverflow: 'Value too high',
32 | rangeUnderflow: 'Value too low',
33 | stepMismatch: 'Invalid step value',
34 | tooLong: 'Too long',
35 | tooShort: 'Too short',
36 | typeMismatch: 'Invalid value',
37 | valueMissing: 'Required'
38 | }
39 |
40 | private warnIfNoInput(foundInput: boolean) {
41 | warning(foundInput, 'Could not find DOM input with HTML validity API')
42 | }
43 |
44 | componentDidMount(): void {
45 | const root = ReactDOM.findDOMNode(this)
46 | if (root) {
47 | let input: WithValidity | null = null
48 | if (/input|textarea|select/.test(root.nodeName.toLowerCase())) {
49 | input = root as unknown as WithValidity
50 | } else if (
51 | root instanceof Element &&
52 | typeof root.querySelector === 'function'
53 | ) {
54 | const { name } = this.props
55 | input = root.querySelector(
56 | `input[name="${name}"],textarea[name="${name}"],select[name="${name}"]`
57 | ) as unknown as WithValidity
58 | }
59 | const foundInput = Boolean(
60 | input && typeof input.setCustomValidity === 'function'
61 | )
62 | if (foundInput) {
63 | this.input = input
64 | }
65 | this.warnIfNoInput(foundInput)
66 | }
67 | }
68 |
69 | validate = (value: unknown, allValues: object): string | undefined => {
70 | const {
71 | input,
72 | props: { validate }
73 | } = this
74 | if (input) {
75 | const validity = input.validity
76 | if (validate) {
77 | const error = validate(value, allValues)
78 | if (input.setCustomValidity && typeof error === 'string') {
79 | input.setCustomValidity(error)
80 | }
81 | if (error) {
82 | return error
83 | }
84 | }
85 | input.setCustomValidity('')
86 | if (validity && !validity.valid) {
87 | if (validity.customError && input.validationMessage) {
88 | return input.validationMessage
89 | }
90 | const errorKey = errorKeys.find(
91 | (key) => (validity as ValidityState)[key as keyof ValidityState]
92 | )
93 | let error =
94 | errorKey && this.props[errorKey as keyof Html5ValidationFieldProps]
95 | if (typeof error === 'function') {
96 | error = error(value, this.props)
97 | }
98 | if (typeof error === 'string') {
99 | input.setCustomValidity(error)
100 | return error
101 | }
102 | }
103 | } else if (validate) {
104 | this.warnIfNoInput(false)
105 | return validate(value, allValues)
106 | }
107 | return undefined
108 | }
109 |
110 | render(): React.ReactElement {
111 | const {
112 | validate,
113 | badInput,
114 | patternMismatch,
115 | rangeOverflow,
116 | rangeUnderflow,
117 | stepMismatch,
118 | tooLong,
119 | tooShort,
120 | typeMismatch,
121 | valueMissing,
122 | innerRef,
123 | ...rest
124 | } = this.props
125 |
126 | // Remove all message keys from rest before passing to Field
127 | const {
128 | badInput: _badInput,
129 | patternMismatch: _patternMismatch,
130 | rangeOverflow: _rangeOverflow,
131 | rangeUnderflow: _rangeUnderflow,
132 | stepMismatch: _stepMismatch,
133 | tooLong: _tooLong,
134 | tooShort: _tooShort,
135 | typeMismatch: _typeMismatch,
136 | valueMissing: _valueMissing,
137 | ...fieldProps
138 | } = rest
139 |
140 | return React.createElement(Field, {
141 | ...fieldProps,
142 | validate: this.validate,
143 | ref: innerRef as React.Ref,
144 | component: 'input'
145 | })
146 | }
147 | }
148 |
149 | function Html5ValidationFieldWithRef(
150 | props: Omit,
151 | ref: React.Ref
152 | ): React.ReactElement {
153 | const { name, ...rest } = props
154 | return
155 | }
156 |
157 | const ForwardedHtml5ValidationField = React.forwardRef<
158 | Html5ValidationField,
159 | Omit
160 | >(Html5ValidationFieldWithRef)
161 |
162 | export default ForwardedHtml5ValidationField
163 | export { Html5ValidationField }
164 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Html5ValidationField from './Html5ValidationField'
2 | export default Html5ValidationField
3 | export { default as Field } from './Html5ValidationField'
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { FieldProps } from 'react-final-form'
2 | import type { FieldValidator } from 'final-form'
3 | import type { Html5ValidationField } from './Html5ValidationField'
4 | import * as React from 'react'
5 |
6 | export interface Messages {
7 | badInput?: string | ((value?: unknown, props?: Record) => string)
8 | patternMismatch?: string | ((value?: unknown, props?: Record) => string)
9 | rangeOverflow?: string | ((value?: unknown, props?: Record) => string)
10 | rangeUnderflow?: string | ((value?: unknown, props?: Record) => string)
11 | stepMismatch?: string | ((value?: unknown, props?: Record) => string)
12 | tooLong?: string | ((value?: unknown, props?: Record) => string)
13 | tooShort?: string | ((value?: unknown, props?: Record) => string)
14 | typeMismatch?: string | ((value?: unknown, props?: Record) => string)
15 | valueMissing?: string | ((value?: unknown, props?: Record) => string)
16 | }
17 |
18 | export interface Html5ValidationFieldProps extends FieldProps, Messages {
19 | validate?: FieldValidator
20 | innerRef?: React.Ref
21 | }
--------------------------------------------------------------------------------
/src/warning.ts:
--------------------------------------------------------------------------------
1 | export default function warning(condition: boolean, message: string): void {
2 | if (!condition && process.env.NODE_ENV !== 'production') {
3 | if (typeof console !== 'undefined') {
4 | console.error(`Warning: ${message}`)
5 | }
6 | try {
7 | throw new Error(`Warning: ${message}`)
8 | } catch (e) { }
9 | }
10 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "module": "esnext",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "declaration": true,
7 | "declarationMap": true,
8 | "sourceMap": true,
9 | "outDir": "dist",
10 | "strict": true,
11 | "moduleResolution": "node",
12 | "allowSyntheticDefaultImports": true,
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "jsx": "react",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": false,
20 | "baseUrl": ".",
21 | "paths": {
22 | "*": ["node_modules/*"]
23 | }
24 | },
25 | "include": ["src/**/*"],
26 | "exclude": ["node_modules", "dist", "src/**/*.test.tsx"]
27 | }
28 |
--------------------------------------------------------------------------------