├── .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 | [![NPM Version](https://img.shields.io/npm/v/react-final-form-html5-validation.svg?style=flat)](https://www.npmjs.com/package/react-final-form-html5-validation) 6 | [![NPM Downloads](https://img.shields.io/npm/dm/react-final-form-html5-validation.svg?style=flat)](https://www.npmjs.com/package/react-final-form-html5-validation) 7 | [![Build Status](https://travis-ci.org/final-form/react-final-form-html5-validation.svg?branch=master)](https://travis-ci.org/final-form/react-final-form-html5-validation) 8 | [![codecov.io](https://codecov.io/gh/final-form/react-final-form-html5-validation/branch/master/graph/badge.svg)](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 | [![Edit 🏁 React Final Form - HTML5 Validation Example](https://codesandbox.io/static/img/play-codesandbox.svg)](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 |
( 50 | 51 |
52 | 53 | 63 |
64 | ... 65 |
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 |
41 | {() => ( 42 | 48 | )} 49 | 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 |
106 | {() => ( 107 | 108 | 109 | 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 |
127 | {() => ( 128 | 134 | )} 135 | 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 |
189 | {() => ( 190 | 191 | {({ input }: TestFieldRenderProps) => } 192 | 193 | )} 194 |
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 |
{}}> 206 | {() => ( 207 | 208 | {({ input }: TestFieldRenderProps) => ( 209 |
div}>{input.value}
210 | )} 211 |
212 | )} 213 |
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 |
{}}> 232 | {() => ( 233 | 234 | {({ input }: TestFieldRenderProps) => ( 235 |
div}>{input.value}
236 | )} 237 |
238 | )} 239 |
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 |
258 | {() => ( 259 | 260 | {({ input }: TestFieldRenderProps) => } 261 | 262 | )} 263 |
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 |
280 | {() => ( 281 | 282 | {({ input }: TestFieldRenderProps) => ( 283 |
div}>{input.value}
284 | )} 285 |
286 | )} 287 |
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 |
305 | {() => ( 306 | 307 | {({ input }: TestFieldRenderProps) => ( 308 |
div}>{input.value}
309 | )} 310 |
311 | )} 312 |
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 |
343 | {() => ( 344 | 345 | {({ input }: TestFieldRenderProps) => ( 346 |
div}>{input.value}
347 | )} 348 |
349 | )} 350 |
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 |
378 | {() => ( 379 | 380 | {({ input }: TestFieldRenderProps) => ( 381 |
div}>{input.value}
382 | )} 383 |
384 | )} 385 |
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 |
400 | {() => ( 401 | 402 | {({ input }: TestFieldRenderProps) => ( 403 |
div}>{input.value}
404 | )} 405 |
406 | )} 407 |
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 |
443 | {() => ( 444 | 445 | {({ input }: TestFieldRenderProps) => ( 446 |
div}>{input.value}
447 | )} 448 |
449 | )} 450 |
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 |
477 | {() => ( 478 | 479 | {({ input }: TestFieldRenderProps) => ( 480 |
div}>{input.value}
481 | )} 482 |
483 | )} 484 |
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 |
511 | {() => ( 512 | 513 | {({ input }: TestFieldRenderProps) => ( 514 |
div}>{input.value}
515 | )} 516 |
517 | )} 518 |
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 |
550 | {() => ( 551 | 552 | {({ input }: TestFieldRenderProps) => ( 553 |
div}>{input.value}
554 | )} 555 |
556 | )} 557 |
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 |
588 | {() => ( 589 | ) => 591 | `Value ${value} should have at least ${props?.minLength} characters.` 592 | } 593 | minLength={8} 594 | name="foo" 595 | > 596 | {({ input }: TestFieldRenderProps) => ( 597 |
div}>{input.value}
598 | )} 599 |
600 | )} 601 |
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 |
624 | {() => ( 625 | 626 | {({ input }: TestFieldRenderProps) =>
{input.value}
} 627 |
628 | )} 629 |
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 |
646 | {() => ( 647 | 648 | {({ input }: TestFieldRenderProps) =>
{input.value}
} 649 |
650 | )} 651 |
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 | --------------------------------------------------------------------------------