├── .prettierrc ├── .gitignore ├── src ├── index.ts ├── types.ts ├── OnBlur.tsx ├── OnFocus.tsx ├── OnChange.tsx ├── OnBlur.test.tsx ├── ExternallyChanged.tsx ├── OnFocus.test.tsx ├── OnChange.test.tsx └── ExternallyChanged.test.tsx ├── .travis.yml ├── .github ├── workflows │ ├── lock.yml │ └── ci.yml ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── tsconfig.json ├── eslint.config.js ├── .babelrc.js ├── LICENSE ├── package-scripts.js ├── rollup.config.js ├── README.md └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | trailingComma: none 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | npm-debug.log* 5 | coverage -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ExternallyChanged } from './ExternallyChanged' 2 | export { default as OnBlur } from './OnBlur' 3 | export { default as OnChange } from './OnChange' 4 | export { default as OnFocus } from './OnFocus' 5 | export * from './types' -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '8' 10 | script: 11 | - npm start validate 12 | after_success: 13 | - npx codecov 14 | - npm install --global semantic-release 15 | # - semantic-release pre && npm publish && semantic-release post 16 | branches: 17 | only: 18 | - master -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export interface ExternallyChangedProps { 4 | name: string 5 | children: (externallyChanged: boolean) => React.ReactNode 6 | } 7 | 8 | export interface OnBlurProps { 9 | name: string 10 | children: () => void 11 | } 12 | 13 | export interface OnChangeProps { 14 | name: string 15 | children: (value: any, previous: any) => void 16 | } 17 | 18 | export interface OnFocusProps { 19 | name: string 20 | children: () => void 21 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "es6"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "declaration": true, 18 | "declarationDir": "dist", 19 | "outDir": "dist" 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["node_modules", "dist", "**/*.test.*"] 23 | } 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tsParser from '@typescript-eslint/parser' 2 | 3 | export default [ 4 | { 5 | ignores: ['node_modules/**', 'coverage/**', 'dist/**'] 6 | }, 7 | { 8 | files: ['**/*.{js,jsx}'], 9 | languageOptions: { 10 | ecmaVersion: 'latest', 11 | sourceType: 'module', 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true 15 | } 16 | } 17 | }, 18 | rules: { 19 | 'no-unused-vars': 'off' 20 | } 21 | }, 22 | { 23 | files: ['**/*.{ts,tsx}'], 24 | languageOptions: { 25 | parser: tsParser, 26 | ecmaVersion: 'latest', 27 | sourceType: 'module', 28 | parserOptions: { 29 | ecmaFeatures: { 30 | jsx: true 31 | } 32 | } 33 | }, 34 | rules: { 35 | 'no-unused-vars': 'off' 36 | } 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /.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 | [ 8 | '@babel/preset-env', 9 | { 10 | loose, 11 | ...(test ? { targets: { node: '8' } } : {}) 12 | } 13 | ], 14 | '@babel/preset-react', 15 | '@babel/preset-typescript' 16 | ], 17 | plugins: [ 18 | '@babel/plugin-syntax-dynamic-import', 19 | '@babel/plugin-syntax-import-meta', 20 | ['@babel/plugin-proposal-class-properties', { loose }], 21 | '@babel/plugin-proposal-json-strings', 22 | [ 23 | '@babel/plugin-proposal-decorators', 24 | { 25 | legacy: true 26 | } 27 | ], 28 | '@babel/plugin-proposal-function-sent', 29 | '@babel/plugin-proposal-export-namespace-from', 30 | '@babel/plugin-proposal-numeric-separator', 31 | '@babel/plugin-proposal-throw-expressions' 32 | ].filter(Boolean) 33 | } 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/OnBlur.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Field, FieldRenderProps } from 'react-final-form' 3 | import { OnBlurProps } from './types' 4 | 5 | interface Props { 6 | children: () => void 7 | meta: { 8 | active?: boolean 9 | } 10 | } 11 | 12 | interface State { 13 | previous: boolean 14 | } 15 | 16 | class OnBlurState extends React.Component { 17 | constructor(props: Props) { 18 | super(props) 19 | this.state = { 20 | previous: !!props.meta.active 21 | } 22 | } 23 | 24 | componentDidUpdate() { 25 | const { 26 | children, 27 | meta: { active } 28 | } = this.props 29 | const { previous } = this.state 30 | if (previous && !active) { 31 | children() 32 | } 33 | if (previous !== !!active) { 34 | this.setState({ previous: !!active }) 35 | } 36 | } 37 | 38 | render() { 39 | return null 40 | } 41 | } 42 | 43 | const OnBlur: React.FC = ({ name, children }) => 44 | React.createElement(Field, { 45 | name, 46 | subscription: { active: true }, 47 | render: (props: FieldRenderProps) => 48 | React.createElement(OnBlurState, { ...props, children }) 49 | }) 50 | 51 | export default OnBlur 52 | -------------------------------------------------------------------------------- /src/OnFocus.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Field, FieldRenderProps } from 'react-final-form' 3 | import { OnFocusProps } from './types' 4 | 5 | interface Props { 6 | children: () => void 7 | meta: { 8 | active?: boolean 9 | } 10 | } 11 | 12 | interface State { 13 | previous: boolean 14 | } 15 | 16 | class OnFocusState extends React.Component { 17 | constructor(props: Props) { 18 | super(props) 19 | this.state = { 20 | previous: !!props.meta.active 21 | } 22 | } 23 | 24 | componentDidUpdate() { 25 | const { 26 | children, 27 | meta: { active } 28 | } = this.props 29 | const { previous } = this.state 30 | if (active && !previous) { 31 | this.setState({ previous: !!active }) 32 | children() 33 | } else if (!active && previous) { 34 | this.setState({ previous: !!active }) 35 | } 36 | } 37 | 38 | render() { 39 | return null 40 | } 41 | } 42 | 43 | const OnFocus: React.FC = ({ name, children }) => 44 | React.createElement(Field, { 45 | name, 46 | subscription: { active: true }, 47 | render: (props: FieldRenderProps) => 48 | React.createElement(OnFocusState, { ...props, children }) 49 | }) 50 | 51 | export default OnFocus 52 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/OnChange.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Field, FieldRenderProps } from 'react-final-form' 3 | import { OnChangeProps } from './types' 4 | 5 | interface Props { 6 | children: (value: any, previous: any) => void 7 | input: { 8 | value: any 9 | } 10 | } 11 | 12 | const OnChangeState: React.FC = ({ children, input }) => { 13 | const previousValueRef = React.useRef(undefined) 14 | const hasInitializedRef = React.useRef(false) 15 | 16 | // The dependency array is intentionally omitted here because this effect 17 | // is designed to run on every render. It compares the current and previous 18 | // values of `input.value` and triggers the `children` callback if they differ. 19 | React.useLayoutEffect(() => { 20 | if (!hasInitializedRef.current) { 21 | hasInitializedRef.current = true 22 | previousValueRef.current = input.value 23 | return 24 | } 25 | 26 | if (input.value !== previousValueRef.current) { 27 | children(input.value, previousValueRef.current) 28 | previousValueRef.current = input.value 29 | } 30 | }) 31 | 32 | return null 33 | } 34 | 35 | const OnChange: React.FC = ({ name, children }) => 36 | React.createElement(Field, { 37 | name, 38 | subscription: { value: true }, 39 | allowNull: true, 40 | render: (props: FieldRenderProps) => 41 | React.createElement(OnChangeState, { ...props, children }) 42 | }) 43 | 44 | export default OnChange 45 | -------------------------------------------------------------------------------- /package-scripts.js: -------------------------------------------------------------------------------- 1 | const npsUtils = require('nps-utils') 2 | 3 | const series = npsUtils.series 4 | const concurrent = npsUtils.concurrent 5 | const rimraf = npsUtils.rimraf 6 | const crossEnv = npsUtils.crossEnv 7 | 8 | module.exports = { 9 | scripts: { 10 | test: { 11 | default: crossEnv('NODE_ENV=test jest --coverage'), 12 | update: crossEnv('NODE_ENV=test jest --coverage --updateSnapshot'), 13 | watch: crossEnv('NODE_ENV=test jest --watch'), 14 | codeCov: crossEnv( 15 | 'cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js' 16 | ), 17 | size: { 18 | description: 'check the size of the bundle', 19 | script: 'bundlesize' 20 | } 21 | }, 22 | build: { 23 | description: 'delete the dist directory and run all builds', 24 | default: series( 25 | rimraf('dist'), 26 | concurrent.nps( 27 | 'build.es', 28 | 'build.cjs', 29 | 'build.umd.main', 30 | 'build.umd.min' 31 | ) 32 | ), 33 | es: { 34 | description: 'run the build with rollup (uses rollup.config.js)', 35 | script: 'rollup --config --environment FORMAT:es' 36 | }, 37 | cjs: { 38 | description: 'run rollup build with CommonJS format', 39 | script: 'rollup --config --environment FORMAT:cjs' 40 | }, 41 | umd: { 42 | min: { 43 | description: 'run the rollup build with sourcemaps', 44 | script: 'rollup --config --sourcemap --environment MINIFY,FORMAT:umd' 45 | }, 46 | main: { 47 | description: 'builds the cjs and umd files', 48 | script: 'rollup --config --sourcemap --environment FORMAT:umd' 49 | } 50 | }, 51 | andTest: series.nps('build', 'test.size') 52 | }, 53 | docs: { 54 | description: 'Generates table of contents in README', 55 | script: 'doctoc README.md' 56 | }, 57 | lint: { 58 | description: 'lint the entire project', 59 | script: 'eslint .' 60 | }, 61 | prettier: { 62 | description: 'Runs prettier on everything', 63 | script: 'prettier --write "**/*.([jt]s*)"' 64 | }, 65 | typecheck: { 66 | description: 'typecheck the entire project', 67 | script: 'tsc --noEmit' 68 | }, 69 | validate: { 70 | description: 71 | 'This runs several scripts to make sure things look good before committing or on clean install', 72 | default: concurrent.nps('lint', 'typecheck', 'build.andTest', 'test') 73 | } 74 | }, 75 | options: { 76 | silent: false 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/OnBlur.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, cleanup } from '@testing-library/react' 3 | import { Form, Field } from 'react-final-form' 4 | import OnBlur from './OnBlur' 5 | 6 | const onSubmitMock = () => {} 7 | 8 | describe('OnBlur', () => { 9 | afterEach(cleanup) 10 | 11 | it('should not call listener on first render', () => { 12 | const spy = jest.fn() 13 | render( 14 |
15 | {() => ( 16 |
17 | 18 | {spy} 19 |
20 | )} 21 |
22 | ) 23 | expect(spy).not.toHaveBeenCalled() 24 | }) 25 | 26 | it('should call listener when field loses focus', () => { 27 | const spy = jest.fn() 28 | const { getByTestId } = render( 29 |
30 | {() => ( 31 |
32 | 33 | 34 | {spy} 35 |
36 | )} 37 |
38 | ) 39 | expect(spy).not.toHaveBeenCalled() 40 | fireEvent.focus(getByTestId('name')) 41 | expect(spy).not.toHaveBeenCalled() 42 | fireEvent.blur(getByTestId('name')) 43 | expect(spy).toHaveBeenCalled() 44 | expect(spy).toHaveBeenCalledTimes(1) 45 | }) 46 | 47 | it('should not call listener when field was not focused', () => { 48 | const spy = jest.fn() 49 | const { getByTestId } = render( 50 |
51 | {() => ( 52 |
53 | 54 | {spy} 55 |
56 | )} 57 |
58 | ) 59 | fireEvent.blur(getByTestId('name')) 60 | expect(spy).not.toHaveBeenCalled() 61 | }) 62 | 63 | it('should call listener multiple times on multiple blur events', () => { 64 | const spy = jest.fn() 65 | const { getByTestId } = render( 66 |
67 | {() => ( 68 |
69 | 70 | {spy} 71 |
72 | )} 73 |
74 | ) 75 | fireEvent.focus(getByTestId('name')) 76 | fireEvent.blur(getByTestId('name')) 77 | expect(spy).toHaveBeenCalledTimes(1) 78 | 79 | fireEvent.focus(getByTestId('name')) 80 | fireEvent.blur(getByTestId('name')) 81 | expect(spy).toHaveBeenCalledTimes(2) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import babel from 'rollup-plugin-babel' 3 | import typescript from 'rollup-plugin-typescript2' 4 | import commonjs from 'rollup-plugin-commonjs' 5 | import { uglify } from 'rollup-plugin-uglify' 6 | import replace from 'rollup-plugin-replace' 7 | import pkg from './package.json' with { type: 'json' } 8 | import ts from 'typescript' 9 | 10 | const makeExternalPredicate = (externalArr) => { 11 | if (externalArr.length === 0) { 12 | return () => false 13 | } 14 | const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`) 15 | return (id) => pattern.test(id) 16 | } 17 | 18 | const minify = process.env.MINIFY 19 | const format = process.env.FORMAT 20 | const es = format === 'es' 21 | const umd = format === 'umd' 22 | const cjs = format === 'cjs' 23 | 24 | let output 25 | 26 | if (es) { 27 | output = { file: `dist/react-final-form-listeners.es.js`, format: 'es' } 28 | } else if (umd) { 29 | if (minify) { 30 | output = { 31 | file: `dist/react-final-form-listeners.umd.min.js`, 32 | format: 'umd' 33 | } 34 | } else { 35 | output = { file: `dist/react-final-form-listeners.umd.js`, format: 'umd' } 36 | } 37 | } else if (cjs) { 38 | output = { file: `dist/react-final-form-listeners.cjs.js`, format: 'cjs' } 39 | } else if (format) { 40 | throw new Error(`invalid format specified: "${format}".`) 41 | } else { 42 | throw new Error('no format specified. --environment FORMAT:xxx') 43 | } 44 | 45 | export default { 46 | input: 'src/index.ts', 47 | output: Object.assign( 48 | { 49 | name: 'react-final-form-listeners', 50 | exports: 'named', 51 | globals: { 52 | react: 'React', 53 | 'prop-types': 'PropTypes', 54 | 'final-form': 'FinalForm', 55 | 'react-final-form': 'ReactFinalForm' 56 | } 57 | }, 58 | output 59 | ), 60 | external: makeExternalPredicate( 61 | umd 62 | ? Object.keys(pkg.peerDependencies || {}) 63 | : [ 64 | ...Object.keys(pkg.dependencies || {}), 65 | ...Object.keys(pkg.peerDependencies || {}) 66 | ] 67 | ), 68 | plugins: [ 69 | resolve({ jsnext: true, main: true }), 70 | typescript({ 71 | typescript: ts, 72 | tsconfigOverride: { 73 | compilerOptions: { 74 | declaration: true, 75 | declarationDir: 'dist' 76 | } 77 | } 78 | }), 79 | commonjs({ include: 'node_modules/**' }), 80 | babel({ 81 | exclude: 'node_modules/**', 82 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 83 | plugins: [['@babel/plugin-transform-runtime', { useESModules: !cjs }]], 84 | runtimeHelpers: true 85 | }), 86 | umd 87 | ? replace({ 88 | 'process.env.NODE_ENV': JSON.stringify( 89 | minify ? 'production' : 'development' 90 | ) 91 | }) 92 | : null, 93 | minify ? uglify() : null 94 | ].filter(Boolean) 95 | } 96 | -------------------------------------------------------------------------------- /src/ExternallyChanged.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Field, FieldRenderProps } from 'react-final-form' 3 | import { ExternallyChangedProps } from './types' 4 | 5 | interface Props { 6 | children: (externallyChanged: boolean) => React.ReactNode 7 | input: { 8 | value: any 9 | } 10 | meta: { 11 | active?: boolean 12 | } 13 | } 14 | 15 | const ExternallyChangedState: React.FC = ({ children, input, meta }) => { 16 | const [externallyChanged, setExternallyChanged] = React.useState(false) 17 | const previousValueRef = React.useRef(input.value) 18 | const hasInitializedRef = React.useRef(false) 19 | const initialRenderPhaseRef = React.useRef(true) 20 | 21 | // The dependency array is intentionally omitted here because this effect 22 | // is designed to run on every render. It tracks value changes and field activity 23 | // to determine if changes were made externally (not by user interaction). 24 | React.useLayoutEffect(() => { 25 | // Initialize tracking on first run 26 | if (!hasInitializedRef.current) { 27 | hasInitializedRef.current = true 28 | previousValueRef.current = input.value 29 | return 30 | } 31 | 32 | // Handle value changes 33 | if (input.value !== previousValueRef.current) { 34 | // Only consider it externally changed if: 35 | // 1. The field is not active AND 36 | // 2. We're past the initial render phase OR the change is from user interaction 37 | const wasExternallyChanged = !meta.active 38 | 39 | // Clear the initial render phase flag after the first real change 40 | if (initialRenderPhaseRef.current) { 41 | initialRenderPhaseRef.current = false 42 | // If this is the first change and field is not active, it's likely initialization 43 | // Skip setting externally changed to avoid false positives during form setup 44 | if (!meta.active) { 45 | previousValueRef.current = input.value 46 | return 47 | } 48 | } 49 | 50 | // Update externally changed state and track the new value 51 | setExternallyChanged(wasExternallyChanged) 52 | previousValueRef.current = input.value 53 | } else if (meta.active && externallyChanged) { 54 | // Reset externally changed when field becomes active after external change 55 | // This allows users to "acknowledge" external changes by focusing the field 56 | setExternallyChanged(false) 57 | } 58 | }) 59 | 60 | return children(externallyChanged) as React.ReactElement 61 | } 62 | 63 | const ExternallyChanged: React.FC = ({ 64 | name, 65 | children 66 | }) => 67 | React.createElement(Field, { 68 | name, 69 | subscription: { active: true, value: true }, 70 | allowNull: true, 71 | render: (props: FieldRenderProps) => 72 | React.createElement(ExternallyChangedState, { ...props, children }) 73 | }) 74 | 75 | export default ExternallyChanged 76 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to 🏁 React Final Form Listeners! 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-listeners/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 Listeners 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-listeners` tag](https://stackoverflow.com/questions/tagged/react-final-form-listeners). 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-listeners/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-listeners/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 Listeners, please include tests. 78 | -------------------------------------------------------------------------------- /src/OnFocus.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, cleanup } from '@testing-library/react' 3 | import { Form, Field } from 'react-final-form' 4 | import OnFocus from './OnFocus' 5 | 6 | const onSubmitMock = () => {} 7 | 8 | describe('OnFocus', () => { 9 | afterEach(cleanup) 10 | 11 | it('should not call listener on first render', () => { 12 | const spy = jest.fn() 13 | render( 14 |
15 | {() => ( 16 |
17 | 18 | {spy} 19 |
20 | )} 21 |
22 | ) 23 | expect(spy).not.toHaveBeenCalled() 24 | }) 25 | 26 | it('should call listener when field gains focus', () => { 27 | const spy = jest.fn() 28 | const { getByTestId } = render( 29 |
30 | {() => ( 31 |
32 | 33 | {spy} 34 |
35 | )} 36 |
37 | ) 38 | expect(spy).not.toHaveBeenCalled() 39 | fireEvent.focus(getByTestId('name')) 40 | expect(spy).toHaveBeenCalled() 41 | expect(spy).toHaveBeenCalledTimes(1) 42 | }) 43 | 44 | it('should call listener multiple times on multiple focus events', () => { 45 | const spy = jest.fn() 46 | const { getByTestId } = render( 47 |
48 | {() => ( 49 |
50 | 51 | {spy} 52 |
53 | )} 54 |
55 | ) 56 | fireEvent.focus(getByTestId('name')) 57 | expect(spy).toHaveBeenCalledTimes(1) 58 | 59 | fireEvent.blur(getByTestId('name')) 60 | fireEvent.focus(getByTestId('name')) 61 | expect(spy).toHaveBeenCalledTimes(2) 62 | }) 63 | 64 | it('should not call listener when field is already focused', () => { 65 | const spy = jest.fn() 66 | const { getByTestId } = render( 67 |
68 | {() => ( 69 |
70 | 71 | {spy} 72 |
73 | )} 74 |
75 | ) 76 | fireEvent.focus(getByTestId('name')) 77 | expect(spy).toHaveBeenCalledTimes(1) 78 | 79 | // Focus the same field again - should not trigger 80 | fireEvent.focus(getByTestId('name')) 81 | expect(spy).toHaveBeenCalledTimes(1) 82 | }) 83 | 84 | it('should call listener again after blur and focus', () => { 85 | const spy = jest.fn() 86 | const { getByTestId } = render( 87 |
88 | {() => ( 89 |
90 | 91 | {spy} 92 |
93 | )} 94 |
95 | ) 96 | 97 | fireEvent.focus(getByTestId('name')) 98 | expect(spy).toHaveBeenCalledTimes(1) 99 | 100 | fireEvent.blur(getByTestId('name')) 101 | fireEvent.focus(getByTestId('name')) 102 | expect(spy).toHaveBeenCalledTimes(2) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏁 React Final Form Listeners 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/react-final-form-listeners.svg?style=flat)](https://www.npmjs.com/package/react-final-form-listeners) 4 | [![NPM Downloads](https://img.shields.io/npm/dm/react-final-form-listeners.svg?style=flat)](https://www.npmjs.com/package/react-final-form-listeners) 5 | [![Build Status](https://travis-ci.org/final-form/react-final-form-listeners.svg?branch=master)](https://travis-ci.org/final-form/react-final-form-listeners) 6 | [![codecov.io](https://codecov.io/gh/final-form/react-final-form-listeners/branch/master/graph/badge.svg)](https://codecov.io/gh/final-form/react-final-form-listeners) 7 | 8 | --- 9 | 10 | 🏁 React Final Form Listeners is a collection of useful components for listening to fields in a [🏁 React Final Form](https://github.com/final-form/react-final-form#-react-final-form). 11 | 12 | ## Installation 13 | 14 | ```bash 15 | npm install --save react-final-form-listeners react-final-form final-form 16 | ``` 17 | 18 | or 19 | 20 | ```bash 21 | yarn add react-final-form-listeners react-final-form final-form 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```jsx 27 | import { Form, Field } from 'react-final-form' 28 | import { OnChange } from 'react-final-form-listeners' 29 | 30 | const MyForm = () => ( 31 |
( 34 | 35 |
36 | 39 | 40 | {(value, previous) => { 41 | // do something 42 | }} 43 | 44 |
45 | ... 46 |
47 | )} 48 | /> 49 | ) 50 | ``` 51 | 52 | ## Table of Contents 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | * [Components](#components) 61 | * [`ExternallyChanged`](#externallychanged) 62 | * [`name : String`](#name--string) 63 | * [`children: (externallyChanged: boolean) => React.Node`](#children-externallychanged-boolean--reactnode) 64 | * [`OnBlur`](#onblur) 65 | * [`name : String`](#name--string-1) 66 | * [`children: () => void`](#children---void) 67 | * [`OnChange`](#onchange) 68 | * [`name : String`](#name--string-2) 69 | * [`children: (value: any, previous: any) => void`](#children-value-any-previous-any--void) 70 | * [`OnFocus`](#onfocus) 71 | * [`name : String`](#name--string-3) 72 | * [`children: () => void`](#children---void-1) 73 | 74 | 75 | 76 | ## Components 77 | 78 | The following can be imported from `react-final-form-listeners`. 79 | 80 | ### `ExternallyChanged` 81 | 82 | Renders is render prop with a `boolean` flag when the specified field was last updated externally (changed while not active). 83 | 84 | #### `name : String` 85 | 86 | The name of the field to listen to. 87 | 88 | #### `children: (externallyChanged: boolean) => React.Node` 89 | 90 | A render prop given the boolean flag. 91 | 92 | ### `OnChange` 93 | 94 | Calls its `children` callback whenever the specified field changes. It renders nothing. 95 | 96 | #### `name : String` 97 | 98 | The name of the field to listen to. 99 | 100 | #### `children: (value: any, previous: any) => void` 101 | 102 | A function that will be called whenever the specified field is changed. It is passed the new value and the previous value. 103 | 104 | ### `OnFocus` 105 | 106 | Calls its `children` callback whenever the specified field becomes active. It renders nothing. 107 | 108 | #### `name : String` 109 | 110 | The name of the field to listen to. 111 | 112 | #### `children: () => void` 113 | 114 | A function that will be called whenever the specified field is changed. It is passed the new value and the previous value. 115 | 116 | ### `OnBlur` 117 | 118 | Calls its `children` callback whenever the specified field is blurred. It renders nothing. 119 | 120 | #### `name : String` 121 | 122 | The name of the field to listen to. 123 | 124 | #### `children: () => void` 125 | 126 | A function that will be called whenever the specified field is blurred. 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-final-form-listeners", 3 | "version": "3.0.0", 4 | "description": "A collection of components to listen to 🏁 React Final Form fields", 5 | "main": "dist/react-final-form-listeners.cjs.js", 6 | "jsnext:main": "dist/react-final-form-listeners.es.js", 7 | "module": "dist/react-final-form-listeners.es.js", 8 | "types": "dist/index.d.ts", 9 | "sideEffects": false, 10 | "files": [ 11 | "dist" 12 | ], 13 | "scripts": { 14 | "start": "nps", 15 | "test": "nps test", 16 | "precommit": "lint-staged && npm start validate" 17 | }, 18 | "author": "Erik Rasmussen (http://github.com/erikras)", 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/final-form/react-final-form-listeners.git" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/final-form/react-final-form-listeners/issues" 26 | }, 27 | "homepage": "https://github.com/final-form/react-final-form-listeners#readme", 28 | "dependencies": { 29 | "@babel/runtime": "^7.27.4" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.27.4", 33 | "@babel/plugin-external-helpers": "^7.27.1", 34 | "@babel/plugin-proposal-class-properties": "^7.18.6", 35 | "@babel/plugin-proposal-decorators": "^7.27.1", 36 | "@babel/plugin-proposal-export-namespace-from": "^7.18.9", 37 | "@babel/plugin-proposal-function-sent": "^7.27.1", 38 | "@babel/plugin-proposal-json-strings": "^7.18.6", 39 | "@babel/plugin-proposal-numeric-separator": "^7.18.6", 40 | "@babel/plugin-proposal-throw-expressions": "^7.27.1", 41 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 42 | "@babel/plugin-syntax-import-meta": "^7.10.4", 43 | "@babel/plugin-transform-runtime": "^7.27.4", 44 | "@babel/preset-env": "^7.27.2", 45 | "@babel/preset-react": "^7.27.1", 46 | "@babel/preset-typescript": "^7.27.1", 47 | "@eslint/js": "^9.28.0", 48 | "@testing-library/dom": "^10.4.0", 49 | "@testing-library/react": "^16.3.0", 50 | "@types/jest": "^29.5.14", 51 | "@types/react": "^19.1.6", 52 | "@types/react-dom": "^19.1.5", 53 | "@typescript-eslint/eslint-plugin": "^8.33.1", 54 | "@typescript-eslint/parser": "^8.33.1", 55 | "babel-core": "^7.0.0-bridge.0", 56 | "babel-jest": "^29.7.0", 57 | "bundlesize": "^0.18.2", 58 | "doctoc": "^2.2.1", 59 | "eslint": "^9.28.0", 60 | "eslint-config-react-app": "^7.0.1", 61 | "eslint-plugin-import": "^2.31.0", 62 | "eslint-plugin-jsx-a11y": "^6.10.2", 63 | "eslint-plugin-react": "^7.37.5", 64 | "eslint-plugin-react-hooks": "^5.2.0", 65 | "final-form": "^5.0.0", 66 | "husky": "^9.1.7", 67 | "jest": "^29.7.0", 68 | "jest-environment-jsdom": "^30.0.0-beta.3", 69 | "lint-staged": "^16.1.0", 70 | "nps": "^5.10.0", 71 | "nps-utils": "^1.7.0", 72 | "prettier": "^3.5.3", 73 | "prettier-eslint-cli": "^8.0.1", 74 | "prop-types": "^15.8.1", 75 | "raf": "^3.4.1", 76 | "react": "^19.1.0", 77 | "react-dom": "^19.1.0", 78 | "react-final-form": "^7.0.0", 79 | "rollup": "^4.41.1", 80 | "rollup-plugin-babel": "^4.4.0", 81 | "rollup-plugin-commonjs": "^10.1.0", 82 | "rollup-plugin-node-resolve": "^5.2.0", 83 | "rollup-plugin-replace": "^2.2.0", 84 | "rollup-plugin-typescript2": "^0.36.0", 85 | "rollup-plugin-uglify": "^6.0.4", 86 | "typescript": "^5.8.3" 87 | }, 88 | "peerDependencies": { 89 | "final-form": ">=5.0.0", 90 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 91 | "react-final-form": ">=7.0.0" 92 | }, 93 | "jest": { 94 | "testEnvironment": "jsdom", 95 | "setupFiles": [ 96 | "raf/polyfill" 97 | ], 98 | "transform": { 99 | "^.+\\.(ts|tsx)$": "babel-jest" 100 | }, 101 | "testMatch": [ 102 | "**/__tests__/**/*.(ts|tsx|js)", 103 | "**/*.(test|spec).(ts|tsx|js)" 104 | ], 105 | "moduleFileExtensions": [ 106 | "ts", 107 | "tsx", 108 | "js", 109 | "jsx", 110 | "json", 111 | "node" 112 | ] 113 | }, 114 | "lint-staged": { 115 | "*.{js*,ts*,json,md,css}": [ 116 | "prettier --write", 117 | "git add" 118 | ] 119 | }, 120 | "bundlesize": [ 121 | { 122 | "path": "dist/react-final-form-listeners.umd.min.js", 123 | "threshold": "2kB" 124 | }, 125 | { 126 | "path": "dist/react-final-form-listeners.es.js", 127 | "threshold": "3kB" 128 | }, 129 | { 130 | "path": "dist/react-final-form-listeners.cjs.js", 131 | "threshold": "3kB" 132 | } 133 | ], 134 | "collective": { 135 | "type": "opencollective", 136 | "url": "https://opencollective.com/final-form" 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/OnChange.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import { Form, Field } from 'react-final-form' 4 | import OnChange from './OnChange' 5 | 6 | const onSubmitMock = () => {} 7 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 8 | 9 | describe('OnChange', () => { 10 | it('should call listener on first render with initial value', () => { 11 | const spy = jest.fn() 12 | render( 13 |
14 | {() => {spy}} 15 |
16 | ) 17 | expect(spy).toHaveBeenCalled() 18 | expect(spy).toHaveBeenCalledWith('bar', '') 19 | }) 20 | 21 | it('should call listener when going from uninitialized to value', () => { 22 | const spy = jest.fn() 23 | const { getByTestId } = render( 24 |
25 | {() => ( 26 | 27 | 28 | {spy} 29 | 30 | )} 31 | 32 | ) 33 | // For uninitialized field, it might not be called initially 34 | fireEvent.change(getByTestId('name'), { target: { value: 'erikras' } }) 35 | expect(spy).toHaveBeenCalled() 36 | expect(spy).toHaveBeenCalledWith('erikras', '') 37 | }) 38 | 39 | it('should call listener when going from initialized to value', () => { 40 | const spy = jest.fn() 41 | const { getByTestId } = render( 42 |
43 | {() => ( 44 | 45 | 46 | {spy} 47 | 48 | )} 49 | 50 | ) 51 | // Should be called initially with "" -> "erik" 52 | expect(spy).toHaveBeenCalledTimes(1) 53 | expect(spy).toHaveBeenCalledWith('erik', '') 54 | 55 | spy.mockClear() 56 | fireEvent.change(getByTestId('name'), { target: { value: 'erikras' } }) 57 | expect(spy).toHaveBeenCalled() 58 | expect(spy).toHaveBeenCalledTimes(1) 59 | expect(spy).toHaveBeenCalledWith('erikras', 'erik') 60 | }) 61 | 62 | it('should call listener when changing to null', () => { 63 | const spy = jest.fn() 64 | const { getByTestId } = render( 65 |
66 | {() => ( 67 | 68 | 69 | {spy} 70 | 71 | )} 72 | 73 | ) 74 | // Should be called initially with "" -> "erikras" 75 | expect(spy).toHaveBeenCalledTimes(1) 76 | expect(spy).toHaveBeenCalledWith('erikras', '') 77 | 78 | spy.mockClear() 79 | fireEvent.change(getByTestId('name'), { target: { value: null } }) 80 | expect(spy).toHaveBeenCalled() 81 | expect(spy).toHaveBeenCalledTimes(1) 82 | expect(spy).toHaveBeenCalledWith('', 'erikras') 83 | }) 84 | 85 | it('should handle quick subsequent changes properly', () => { 86 | const toppings = ['Pepperoni', 'Mushrooms', 'Olives'] 87 | const { getByTestId } = render( 88 |
89 | {({ form }) => ( 90 | 91 | 97 | 98 | {(next: any) => { 99 | if (next) { 100 | return form.change('toppings', toppings) 101 | } 102 | }} 103 | 104 | {toppings.length > 0 && 105 | toppings.map((topping) => { 106 | return ( 107 | 115 | ) 116 | })} 117 | 118 | {(next: any) => { 119 | form.change( 120 | 'everything', 121 | next && next.length === toppings.length 122 | ) 123 | }} 124 | 125 | 126 | )} 127 |
128 | ) 129 | const everythingCheckbox = getByTestId('everything') as HTMLInputElement 130 | const pepperoniCheckbox = getByTestId('Pepperoni') as HTMLInputElement 131 | const mushroomsCheckbox = getByTestId('Mushrooms') as HTMLInputElement 132 | const olivesCheckbox = getByTestId('Olives') as HTMLInputElement 133 | 134 | expect(everythingCheckbox.checked).toBe(false) 135 | expect(pepperoniCheckbox.checked).toBe(false) 136 | expect(mushroomsCheckbox.checked).toBe(false) 137 | expect(olivesCheckbox.checked).toBe(false) 138 | 139 | fireEvent.click(pepperoniCheckbox) 140 | expect(pepperoniCheckbox.checked).toBe(true) 141 | expect(everythingCheckbox.checked).toBe(false) 142 | 143 | fireEvent.click(mushroomsCheckbox) 144 | expect(mushroomsCheckbox.checked).toBe(true) 145 | expect(everythingCheckbox.checked).toBe(false) 146 | 147 | fireEvent.click(olivesCheckbox) 148 | expect(olivesCheckbox.checked).toBe(true) 149 | expect(everythingCheckbox.checked).toBe(true) 150 | 151 | fireEvent.click(olivesCheckbox) 152 | expect(olivesCheckbox.checked).toBe(false) 153 | expect(everythingCheckbox.checked).toBe(false) 154 | 155 | fireEvent.click(everythingCheckbox) 156 | expect(pepperoniCheckbox.checked).toBe(true) 157 | expect(mushroomsCheckbox.checked).toBe(true) 158 | expect(olivesCheckbox.checked).toBe(true) 159 | expect(everythingCheckbox.checked).toBe(true) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /src/ExternallyChanged.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, cleanup } from '@testing-library/react' 3 | import { Form, Field } from 'react-final-form' 4 | import ExternallyChanged from './ExternallyChanged' 5 | 6 | const onSubmitMock = () => {} 7 | 8 | afterEach(cleanup) 9 | 10 | describe('ExternallyChanged', () => { 11 | it('should indicate false for externally changed on first render', () => { 12 | const spy = jest.fn() 13 | render( 14 |
15 | {() => {spy}} 16 |
17 | ) 18 | expect(spy).toHaveBeenCalled() 19 | // The component might be called multiple times during initialization 20 | // but we care about the final state being false 21 | const calls = spy.mock.calls 22 | const lastCall = calls[calls.length - 1] 23 | expect(lastCall[0]).toBe(false) 24 | }) 25 | 26 | it('should indicate externally changed when form changes value externally', () => { 27 | const spy = jest.fn() 28 | const { getByTestId } = render( 29 |
30 | {({ form }) => ( 31 |
32 | {spy} 33 | 40 |
41 | )} 42 |
43 | ) 44 | 45 | spy.mockClear() 46 | fireEvent.click(getByTestId('external-button')) 47 | expect(spy).toHaveBeenCalledWith(true) 48 | }) 49 | 50 | it('should not indicate externally changed when user changes value', () => { 51 | const spy = jest.fn() 52 | const { getByTestId } = render( 53 |
54 | {() => ( 55 |
56 | 57 | {spy} 58 |
59 | )} 60 |
61 | ) 62 | 63 | spy.mockClear() 64 | // Focus the field first to make it active 65 | fireEvent.focus(getByTestId('foo')) 66 | fireEvent.change(getByTestId('foo'), { target: { value: 'baz' } }) 67 | expect(spy).toHaveBeenCalledWith(false) 68 | }) 69 | 70 | it('should reset to false after external change when user focuses field', () => { 71 | const spy = jest.fn() 72 | const { getByTestId } = render( 73 |
74 | {({ form }) => ( 75 |
76 | 77 | {spy} 78 | 85 |
86 | )} 87 |
88 | ) 89 | 90 | // Clear initial calls 91 | spy.mockClear() 92 | 93 | // Make external change 94 | fireEvent.click(getByTestId('external-button')) 95 | expect(spy).toHaveBeenCalledWith(true) 96 | 97 | spy.mockClear() 98 | fireEvent.focus(getByTestId('foo')) 99 | 100 | // The focus event should reset the externally changed state 101 | // but since the value didn't change, we might need to check differently 102 | if (spy.mock.calls.length > 0) { 103 | expect(spy).toHaveBeenCalledWith(false) 104 | } 105 | }) 106 | 107 | it('should reset externally changed state when field becomes active after external change', () => { 108 | const spy = jest.fn() 109 | const { getByTestId } = render( 110 |
111 | {({ form }) => ( 112 |
113 | 114 | {spy} 115 | 122 |
123 | )} 124 |
125 | ) 126 | 127 | // Clear initial calls 128 | spy.mockClear() 129 | 130 | // Make external change - this should set externallyChanged to true 131 | fireEvent.click(getByTestId('external-button')) 132 | expect(spy).toHaveBeenCalledWith(true) 133 | 134 | // Don't clear the spy - we want to see subsequent calls 135 | // Focus the field to make it active - this should trigger the reset branch 136 | fireEvent.focus(getByTestId('foo')) 137 | 138 | // The component should call the children with false when field becomes active 139 | // while externallyChanged was true 140 | expect(spy).toHaveBeenCalledWith(false) 141 | }) 142 | 143 | it('should handle first change when field is active', () => { 144 | const spy = jest.fn() 145 | const { getByTestId } = render( 146 |
147 | {({ form }) => ( 148 |
149 | 150 | {spy} 151 | 162 |
163 | )} 164 |
165 | ) 166 | 167 | // Clear initial calls 168 | spy.mockClear() 169 | 170 | // Make the field active first, then change it - this should test the branch 171 | // where initialRenderPhaseRef.current is true but meta.active is also true 172 | fireEvent.focus(getByTestId('foo')) 173 | fireEvent.click(getByTestId('active-change-button')) 174 | 175 | // Since the field was active when changed, it should not be considered external 176 | expect(spy).toHaveBeenCalledWith(false) 177 | }) 178 | }) 179 | --------------------------------------------------------------------------------