diff --git a/package.json b/package.json index 7538a1e..a7bd3e5 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "peerDependencies": { "final-form": ">=5.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^18.2.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-final-form": ">=7.0.0" }, "jest": { diff --git a/src/Html5ValidationField.test.tsx b/src/Html5ValidationField.test.tsx index bddae60..701d934 100644 --- a/src/Html5ValidationField.test.tsx +++ b/src/Html5ValidationField.test.tsx @@ -1,6 +1,5 @@ import React from 'react' -import ReactDOM from 'react-dom' -import { render, cleanup } from '@testing-library/react' +import { render, cleanup, waitFor } from '@testing-library/react' import { Form, FieldRenderProps, FieldInputProps } from 'react-final-form' import Html5ValidationField, { Html5ValidationField as Html5ValidationFieldClass @@ -160,99 +159,69 @@ describe('Html5ValidationField', () => { }) describe('Html5ValidationField.validity', () => { - let findDOMNodeSpy: jest.SpyInstance - afterEach(() => { - if (findDOMNodeSpy) { - findDOMNodeSpy.mockRestore() - } - }) - const mockFindNode = (querySelector: jest.Mock) => { - const div = document.createElement('div') - div.querySelector = querySelector - findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div) - return div - } - - it('should use the root node if it is an input element', () => { - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = jest.fn() - Object.defineProperty(input, 'validity', { - value: { valid: true } as ValidityState, - configurable: true - }) - findDOMNodeSpy = jest - .spyOn(ReactDOM, 'findDOMNode') - .mockReturnValue(input) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => } - - )} -
- ) - expect(input.setCustomValidity).toHaveBeenCalled() - }) - - it('should search DOM for input if the root is not the input', () => { - const input = document.createElement('input') - input.name = 'foo' - const querySelector = jest.fn().mockReturnValue(input) - const div = mockFindNode(querySelector) - render( -
{}}> + const renderWithNestedInput = ({ + name = 'foo', + validity = { valid: true } as ValidityState, + validationMessage, + validate, + fieldProps = {}, + initialValues = {} + }: { + name?: string + validity?: ValidityState + validationMessage?: string + validate?: (value: unknown, allValues: object) => unknown + fieldProps?: Record + initialValues?: Record + } = {}) => { + const setCustomValidity = jest.fn() + const result = render( + {() => ( - + {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
+
+ { + if (node) { + node.setCustomValidity = setCustomValidity + Object.defineProperty(node, 'validity', { + value: validity, + configurable: true + }) + if (validationMessage) { + Object.defineProperty(node, 'validationMessage', { + value: validationMessage, + configurable: true + }) + } + } + }} + /> +
)}
)} ) - expect(querySelector).toHaveBeenCalled() - expect(querySelector).toHaveBeenCalledTimes(1) - const calls = querySelector.mock.calls - if (calls.length > 0) { - expect(calls[0][0]).toBe( - 'input[name="foo"],textarea[name="foo"],select[name="foo"]' - ) - } - }) + return { ...result, setCustomValidity } + } - it('should search DOM for input if the root is not the input, even for deep fields', () => { - const input = document.createElement('input') - input.name = 'foo.bar' - const querySelector = jest.fn().mockReturnValue(input) - const div = mockFindNode(querySelector) - render( -
{}}> - {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
+ it('should use the root node if it is an input element', async () => { + const setCustomValiditySpy = jest.spyOn( + HTMLInputElement.prototype, + 'setCustomValidity' ) - expect(querySelector).toHaveBeenCalled() - expect(querySelector).toHaveBeenCalledTimes(1) - const calls = querySelector.mock.calls - if (calls.length > 0) { - expect(calls[0][0]).toBe( - 'input[name="foo.bar"],textarea[name="foo.bar"],select[name="foo.bar"]' - ) - } - }) - it('should fail silently if no DOM node could be found (probably SSR)', () => { - const consoleSpy = jest - .spyOn(global.console, 'error') - .mockImplementation(() => {}) - findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(null) render(
{() => ( @@ -262,31 +231,64 @@ describe('Html5ValidationField', () => { )}
) - expect(consoleSpy).not.toHaveBeenCalled() - consoleSpy.mockRestore() + await waitFor(() => { + expect(setCustomValiditySpy).toHaveBeenCalled() + }) + setCustomValiditySpy.mockRestore() + }) + + it('should search DOM for input if the root is not the input', async () => { + const querySelectorSpy = jest.spyOn( + HTMLElement.prototype, + 'querySelector' + ) + const { setCustomValidity } = renderWithNestedInput() + await waitFor(() => expect(setCustomValidity).toHaveBeenCalled()) + expect(querySelectorSpy).toHaveBeenCalledWith( + 'input[name="foo"],textarea[name="foo"],select[name="foo"]' + ) + querySelectorSpy.mockRestore() + }) + + it('should search DOM for input if the root is not the input, even for deep fields', async () => { + const querySelectorSpy = jest.spyOn( + HTMLElement.prototype, + 'querySelector' + ) + const { setCustomValidity } = renderWithNestedInput({ name: 'foo.bar' }) + await waitFor(() => expect(setCustomValidity).toHaveBeenCalled()) + expect(querySelectorSpy).toHaveBeenCalledWith( + 'input[name="foo.bar"],textarea[name="foo.bar"],select[name="foo.bar"]' + ) + querySelectorSpy.mockRestore() }) it('should warn if no input could be found because DOM node has no querySelector API', () => { const consoleSpy = jest .spyOn(global.console, 'error') .mockImplementation(() => {}) - const div = document.createElement('div') - Object.defineProperty(div, 'querySelector', { - value: undefined, - configurable: true - }) render(
{() => ( {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
+
{ + if (node) { + Object.defineProperty(node, 'querySelector', { + value: undefined, + configurable: true + }) + } + }} + > + {input.value} +
)}
)}
) - expect(consoleSpy).toHaveBeenCalled() expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy).toHaveBeenCalledWith( 'Warning: Could not find DOM input with HTML validity API' @@ -298,349 +300,110 @@ describe('Html5ValidationField', () => { const consoleSpy = jest .spyOn(global.console, 'error') .mockImplementation(() => {}) - const querySelector = jest.fn(() => null) - const div = mockFindNode(querySelector) render(
{() => ( - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} + {({ input }: TestFieldRenderProps) =>
{input.value}
}
)}
) - expect(querySelector).toHaveBeenCalled() - expect(querySelector).toHaveBeenCalledTimes(1) - expect(querySelector).toHaveBeenCalledWith( - 'input[name="foo"],textarea[name="foo"],select[name="foo"]' - ) - expect(consoleSpy).toHaveBeenCalled() - expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy).toHaveBeenCalledWith( 'Warning: Could not find DOM input with HTML validity API' ) consoleSpy.mockRestore() }) - it('should read/write validity from/to the input', () => { - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { + it('should read/write validity from/to the input', async () => { + const { setCustomValidity } = renderWithNestedInput({ + validity: { valueMissing: true, valid: false - } as ValidityState, - configurable: true + } as ValidityState }) - const querySelector = jest.fn().mockReturnValue(input) - const div = mockFindNode(querySelector) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) - expect(setCustomValidity).toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalledTimes(2) + await waitFor(() => expect(setCustomValidity).toHaveBeenCalledTimes(2)) expect(setCustomValidity.mock.calls[0][0]).toBe('') expect(setCustomValidity.mock.calls[1][0]).toBe('Required') }) - it('should use field-level validation function', () => { + it('should use field-level validation function', async () => { const validate = jest.fn().mockReturnValue('bar') - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { valid: true } as ValidityState, - configurable: true + const { setCustomValidity } = renderWithNestedInput({ + validate, + initialValues: { foo: 'test' } }) - const querySelector = jest.fn().mockReturnValue(input) - const div = document.createElement('div') - div.querySelector = querySelector - findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) - expect(validate).toHaveBeenCalled() - expect(validate).toHaveBeenCalledTimes(1) + await waitFor(() => expect(validate).toHaveBeenCalled()) expect(validate.mock.calls[0][0]).toBe('test') + expect(setCustomValidity).toHaveBeenCalledWith('bar') }) - it('should not call setCustomValidity if no validity API is found', () => { + it('should not call setCustomValidity if validation error is not a string', async () => { const consoleSpy = jest .spyOn(global.console, 'error') .mockImplementation(() => {}) - const querySelector = jest.fn(() => null) - const div = mockFindNode(querySelector) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) - expect(consoleSpy).toHaveBeenCalled() - expect(consoleSpy).toHaveBeenCalledTimes(1) - expect(consoleSpy).toHaveBeenCalledWith( - 'Warning: Could not find DOM input with HTML validity API' - ) - consoleSpy.mockRestore() - }) - - it('should not call setCustomValidity if validation error is not a string', () => { - const consoleSpy = jest - .spyOn(global.console, 'error') - .mockImplementation(() => {}) - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { + const validate = jest.fn(() => ({ notAString: true })) + const { setCustomValidity } = renderWithNestedInput({ + validate, + initialValues: { foo: 'test' }, + validity: { valid: false, valueMissing: false - } as ValidityState, - configurable: true - }) - const querySelector = jest.fn().mockReturnValue(input) - const div = document.createElement('div') - div.querySelector = querySelector - findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div) - const validate = () => ({ notAString: true }) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) - expect(consoleSpy).not.toHaveBeenCalled() - setCustomValidity.mock.calls.forEach((call) => { - expect(call[0]).toBe('') + } as ValidityState }) - consoleSpy.mockRestore() - }) - - it('should not call setCustomValidity if no validation error', () => { - const consoleSpy = jest - .spyOn(global.console, 'error') - .mockImplementation(() => {}) - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { - valid: true - } as ValidityState, - configurable: true - }) - const querySelector = jest.fn().mockReturnValue(input) - const div = mockFindNode(querySelector) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) + await waitFor(() => expect(validate).toHaveBeenCalled()) expect(consoleSpy).not.toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalledTimes(1) - expect(setCustomValidity.mock.calls[0][0]).toBe('') + expect(setCustomValidity).not.toHaveBeenCalled() consoleSpy.mockRestore() }) - it('should not call setCustomValidity if valid === true', () => { + it('should not call setCustomValidity with an error if no validation error', async () => { const consoleSpy = jest .spyOn(global.console, 'error') .mockImplementation(() => {}) - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { - valid: true - } as ValidityState, - configurable: true - }) - const querySelector = jest.fn().mockReturnValue(input) - const div = mockFindNode(querySelector) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) + const { setCustomValidity } = renderWithNestedInput() + await waitFor(() => expect(setCustomValidity).toHaveBeenCalledTimes(1)) expect(consoleSpy).not.toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalledTimes(1) expect(setCustomValidity.mock.calls[0][0]).toBe('') consoleSpy.mockRestore() }) - it('should report back validity custom error to Final Form', () => { - const consoleSpy = jest - .spyOn(global.console, 'error') - .mockImplementation(() => {}) - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { + it('should report back validity custom error to Final Form', async () => { + const { setCustomValidity } = renderWithNestedInput({ + validity: { valid: false, customError: true } as ValidityState, - configurable: true - }) - Object.defineProperty(input, 'validationMessage', { - value: 'Ooh, how custom!', - configurable: true + validationMessage: 'Ooh, how custom!' }) - const querySelector = jest.fn().mockReturnValue(input) - const div = mockFindNode(querySelector) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) - expect(consoleSpy).not.toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalled() - expect(setCustomValidity).toHaveBeenCalledTimes(1) + await waitFor(() => expect(setCustomValidity).toHaveBeenCalledTimes(1)) expect(setCustomValidity.mock.calls[0][0]).toBe('') - consoleSpy.mockRestore() }) - it('should support functions as default error keys', () => { - const setCustomValidity = jest.fn() - const input = document.createElement('input') - input.name = 'foo' - input.setCustomValidity = setCustomValidity - Object.defineProperty(input, 'validity', { - value: { + it('should support functions as default error keys', async () => { + const { setCustomValidity } = renderWithNestedInput({ + validity: { tooShort: true, valid: false } as ValidityState, - configurable: true + initialValues: { foo: 'bar' }, + fieldProps: { + minLength: 8, + tooShort: (value?: unknown, props?: Record) => + `Value ${value} should have at least ${props?.minLength} characters.` + } }) - const querySelector = jest.fn().mockReturnValue(input) - const div = document.createElement('div') - div.querySelector = querySelector - findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(div) - render( -
- {() => ( - ) => - `Value ${value} should have at least ${props?.minLength} characters.` - } - minLength={8} - name="foo" - > - {({ input }: TestFieldRenderProps) => ( -
div}>{input.value}
- )} -
- )} -
- ) - expect(setCustomValidity).toHaveBeenCalledTimes(2) - expect(setCustomValidity.mock.calls[0][0]).toBe('') + await waitFor(() => expect(setCustomValidity).toHaveBeenCalledTimes(2)) expect(setCustomValidity.mock.calls[1][0]).toBe( 'Value bar should have at least 8 characters.' ) }) - it('should warn if the root node is not an input and has no querySelector API', () => { - const consoleSpy = jest - .spyOn(global.console, 'error') - .mockImplementation(() => {}) - const root = document.createElement('div') - Object.defineProperty(root, 'querySelector', { - value: undefined, - configurable: true - }) - const findDOMNodeSpy = jest - .spyOn(ReactDOM, 'findDOMNode') - .mockReturnValue(root) - render( -
- {() => ( - - {({ input }: TestFieldRenderProps) =>
{input.value}
} -
- )} -
- ) - expect(consoleSpy).toHaveBeenCalledWith( - 'Warning: Could not find DOM input with HTML validity API' - ) - consoleSpy.mockRestore() - findDOMNodeSpy.mockRestore() - }) - it('should use validate prop when no input element is found', async () => { const validate = jest.fn().mockReturnValue('Validation error') const consoleSpy = jest .spyOn(global.console, 'error') .mockImplementation(() => {}) - findDOMNodeSpy = jest.spyOn(ReactDOM, 'findDOMNode').mockReturnValue(null) render(
{() => ( @@ -650,14 +413,11 @@ describe('Html5ValidationField', () => { )}
) - // Wait for component to mount and validate - await new Promise((resolve) => setTimeout(resolve, 0)) + await waitFor(() => expect(validate).toHaveBeenCalled()) expect(consoleSpy).toHaveBeenCalledWith( 'Warning: Could not find DOM input with HTML validity API' ) - expect(validate).toHaveBeenCalled() consoleSpy.mockRestore() - findDOMNodeSpy.mockRestore() }) }) }) diff --git a/src/Html5ValidationField.tsx b/src/Html5ValidationField.tsx index c8dd60d..2e98886 100644 --- a/src/Html5ValidationField.tsx +++ b/src/Html5ValidationField.tsx @@ -1,6 +1,5 @@ import * as React from 'react' -import ReactDOM from 'react-dom' -import { Field } from 'react-final-form' +import { Field, FieldRenderProps } from 'react-final-form' import { Html5ValidationFieldProps } from './types' import warning from './warning' @@ -24,6 +23,7 @@ interface WithValidity { class Html5ValidationField extends React.Component { private input: WithValidity | null = null + private fieldRef = React.createRef() static defaultProps = { badInput: 'Incorrect input', @@ -42,7 +42,11 @@ class Html5ValidationField extends React.Component { } componentDidMount(): void { - const root = ReactDOM.findDOMNode(this) + this.findInput() + } + + private findInput = (): void => { + const root = this.fieldRef.current if (root) { let input: WithValidity | null = null if (/input|textarea|select/.test(root.nodeName.toLowerCase())) { @@ -120,6 +124,9 @@ class Html5ValidationField extends React.Component { typeMismatch, valueMissing, innerRef, + component, + render, + children, ...rest } = this.props @@ -137,25 +144,66 @@ class Html5ValidationField extends React.Component { ...fieldProps } = rest - return React.createElement(Field, { - ...fieldProps, - validate: this.validate, - ref: innerRef as React.Ref, - component: 'input' - }) + // Merge innerRef with fieldRef + const mergedRef = (node: HTMLElement | null) => { + ;(this.fieldRef as React.MutableRefObject).current = + node + if (typeof innerRef === 'function') { + innerRef(node) + } else if (innerRef) { + ;(innerRef as React.MutableRefObject).current = node + } + } + + // Wrap render function to inject ref + const wrappedRender = ( + fieldProps: FieldRenderProps + ) => { + // Call user's render/children function if provided + const userRender = render || children + if (userRender && typeof userRender === 'function') { + const element = userRender(fieldProps) + // Clone and inject ref + return React.isValidElement(element) + ? React.cloneElement(element, { + ref: mergedRef + } as React.RefAttributes) + : element + } + // Default: render input with ref and pass through HTML field props + return React.createElement(component || 'input', { + ...fieldProps, + ...fieldProps.input, + ref: mergedRef + }) + } + + const validateField = this.validate + const FieldComponent = Field as React.ComponentType< + typeof fieldProps & { + children: typeof wrappedRender + validate: typeof validateField + } + > + + return ( + + {wrappedRender} + + ) } } function Html5ValidationFieldWithRef( props: Omit, - ref: React.Ref + ref: React.Ref ): React.ReactElement { const { name, ...rest } = props return } const ForwardedHtml5ValidationField = React.forwardRef< - Html5ValidationField, + HTMLElement, Omit >(Html5ValidationFieldWithRef) diff --git a/src/index.ts b/src/index.ts index 0344df5..b768d9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ import Html5ValidationField from './Html5ValidationField' export default Html5ValidationField -export { default as Field } from './Html5ValidationField' \ No newline at end of file +export { default as Field } from './Html5ValidationField' diff --git a/src/types.ts b/src/types.ts index 50ce5fa..c0ce7e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,18 +4,36 @@ import type { Html5ValidationField } from './Html5ValidationField' import * as React from 'react' export interface Messages { - badInput?: string | ((value?: unknown, props?: Record) => string) - patternMismatch?: string | ((value?: unknown, props?: Record) => string) - rangeOverflow?: string | ((value?: unknown, props?: Record) => string) - rangeUnderflow?: string | ((value?: unknown, props?: Record) => string) - stepMismatch?: string | ((value?: unknown, props?: Record) => string) - tooLong?: string | ((value?: unknown, props?: Record) => string) - tooShort?: string | ((value?: unknown, props?: Record) => string) - typeMismatch?: string | ((value?: unknown, props?: Record) => string) - valueMissing?: string | ((value?: unknown, props?: Record) => string) + badInput?: + | string + | ((value?: unknown, props?: Record) => string) + patternMismatch?: + | string + | ((value?: unknown, props?: Record) => string) + rangeOverflow?: + | string + | ((value?: unknown, props?: Record) => string) + rangeUnderflow?: + | string + | ((value?: unknown, props?: Record) => string) + stepMismatch?: + | string + | ((value?: unknown, props?: Record) => string) + tooLong?: + | string + | ((value?: unknown, props?: Record) => string) + tooShort?: + | string + | ((value?: unknown, props?: Record) => string) + typeMismatch?: + | string + | ((value?: unknown, props?: Record) => string) + valueMissing?: + | string + | ((value?: unknown, props?: Record) => string) } export interface Html5ValidationFieldProps extends FieldProps, Messages { validate?: FieldValidator - innerRef?: React.Ref -} \ No newline at end of file + innerRef?: React.Ref +} diff --git a/src/warning.ts b/src/warning.ts index 9db7574..dbe56f3 100644 --- a/src/warning.ts +++ b/src/warning.ts @@ -5,6 +5,6 @@ export default function warning(condition: boolean, message: string): void { } try { throw new Error(`Warning: ${message}`) - } catch (e) { } + } catch (e) {} } -} \ No newline at end of file +}