Skip to content

Commit 8888841

Browse files
committed
Add hook to check form validity on submission
By default forms handle validation automatically when submitted, displaying an error message and focusing the first control with an error. This hook is useful if the caller wants to customize how validation error messages are presented on submission.
1 parent 5923074 commit 8888841

File tree

3 files changed

+206
-0
lines changed

3 files changed

+206
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { mount } from 'enzyme';
2+
import { useState, useId } from 'preact/hooks';
3+
4+
import { Input } from '../../components/input';
5+
import { useValidateOnSubmit } from '../use-validate-on-submit';
6+
7+
// Example of a simple form that uses this hook to implement custom display of
8+
// validation messages.
9+
function CustomForm({ onSelectURL }) {
10+
const [url, setURL] = useState();
11+
const [error, setError] = useState();
12+
const errorId = useId();
13+
14+
const onSubmit = useValidateOnSubmit(() => {
15+
onSelectURL(url);
16+
});
17+
18+
const onChangeURL = event => {
19+
const url = event.target.value;
20+
setURL(undefined);
21+
22+
try {
23+
new URL(url);
24+
setError(undefined);
25+
setURL(url);
26+
} catch {
27+
setError('Not a valid URL');
28+
}
29+
};
30+
31+
return (
32+
<form onSubmit={onSubmit} noValidate>
33+
<Input
34+
onChange={onChangeURL}
35+
required
36+
aria-label="URL"
37+
aria-describedby={errorId}
38+
error={error}
39+
/>
40+
<div id={errorId}>{error}</div>
41+
<button type="submit">Submit</button>
42+
</form>
43+
);
44+
}
45+
46+
describe('useValidateOnSubmit', () => {
47+
function changeURL(form, url) {
48+
const input = form.find('input');
49+
input.getDOMNode().value = url;
50+
input.simulate('change');
51+
}
52+
53+
function submitForm(form) {
54+
form.find('form').simulate('submit');
55+
}
56+
57+
it('should invoke callback if form has no errors', () => {
58+
const onSelectURL = sinon.stub();
59+
const form = mount(<CustomForm onSelectURL={onSelectURL} />);
60+
changeURL(form, 'https://example.com');
61+
submitForm(form);
62+
assert.calledWith(onSelectURL, 'https://example.com');
63+
});
64+
65+
it('should invoke change event for empty, required inputs', () => {
66+
const onSelectURL = sinon.stub();
67+
const form = mount(<CustomForm onSelectURL={onSelectURL} />);
68+
const input = form.find('input');
69+
const onChange = sinon.stub();
70+
input.getDOMNode().addEventListener('change', onChange);
71+
72+
submitForm(form);
73+
74+
assert.notCalled(onSelectURL);
75+
assert.calledOnce(onChange);
76+
});
77+
78+
it('should not invoke callback if form has errors', () => {
79+
const onSelectURL = sinon.stub();
80+
const form = mount(<CustomForm onSelectURL={onSelectURL} />);
81+
changeURL(form, 'not valid');
82+
submitForm(form);
83+
assert.notCalled(onSelectURL);
84+
});
85+
86+
it('should focus first input with an error', () => {
87+
const onSelectURL = sinon.stub();
88+
const form = mount(<CustomForm onSelectURL={onSelectURL} />);
89+
changeURL(form, 'not valid');
90+
91+
const input = form.find('input').getDOMNode();
92+
const focusStub = sinon.stub(input, 'focus');
93+
submitForm(form);
94+
95+
assert.calledOnce(focusStub);
96+
});
97+
98+
// Create a fake `Event` for which we can control the `target` property.
99+
// We do this in order to call the event handler directly, rather than
100+
// using `target.dispatchEvent`. This makes catching the exception easier.
101+
function createFakeEvent({ type, target }) {
102+
return { type, target, preventDefault: sinon.stub() };
103+
}
104+
105+
it('should throw if handler received non-"submit" event', () => {
106+
function InvalidUsage() {
107+
const onSubmit = useValidateOnSubmit(() => {});
108+
109+
// eslint-disable-next-line
110+
return <form onClick={onSubmit} />;
111+
}
112+
const wrapper = mount(<InvalidUsage />);
113+
assert.throws(() => {
114+
const formEl = wrapper.find('form').getDOMNode();
115+
const event = createFakeEvent({ type: 'click', target: formEl });
116+
wrapper.find('form').prop('onClick')(event);
117+
}, 'Event type is not "submit"');
118+
});
119+
120+
it('should throw if handler is not a form', () => {
121+
function InvalidUsage() {
122+
const onSubmit = useValidateOnSubmit(() => {});
123+
124+
// eslint-disable-next-line
125+
return <button onSubmit={onSubmit} type="button" />;
126+
}
127+
const wrapper = mount(<InvalidUsage />);
128+
assert.throws(() => {
129+
const buttonEl = wrapper.find('button').getDOMNode();
130+
const event = createFakeEvent({ type: 'submit', target: buttonEl });
131+
wrapper.find('button').prop('onSubmit')(event);
132+
}, 'Event target is not a form');
133+
});
134+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Return a form "submit" event handler that validates the form using
3+
* {@link HTMLFormElement.checkValidity}.
4+
*
5+
* If the check passes, `onValid` is invoked. Otherwise the first control in
6+
* {@link HTMLFormElement.elements} with an error is focused. This will allow
7+
* the user to correct the error, and also cause screen readers to announce
8+
* the current validation state and errors.
9+
*
10+
* This hook is useful for forms which want to display a custom presentation
11+
* of validation errors. Forms using the browser's built-in validation error
12+
* display do not need to use this.
13+
*
14+
* To show custom validation errors, the consumer should:
15+
*
16+
* - Ensure that all input controls validate their input on "change" events.
17+
* - Ensure that validation errors are displayed for each control. The input
18+
* fields must link their validation errors using `aria-describedby` and
19+
* indicate their state using `aria-invalid`.
20+
* - Set the `noValidate` property on the `<form>` to disable the native
21+
* validation UI message
22+
* - Call this hook to create a "submit" event handler and pass it to
23+
* the form's `submit` prop.
24+
*
25+
* See also https://react-spectrum.adobe.com/react-aria/forms.html.
26+
*/
27+
export function useValidateOnSubmit(
28+
onValid: () => void,
29+
): (e: SubmitEvent) => void {
30+
const onSubmit = (event: SubmitEvent) => {
31+
if (event.type !== 'submit') {
32+
throw new Error('Event type is not "submit"');
33+
}
34+
const formEl = event.target;
35+
if (!(formEl instanceof HTMLFormElement)) {
36+
throw new Error('Event target is not a form');
37+
}
38+
39+
event.preventDefault();
40+
41+
if (formEl.checkValidity()) {
42+
onValid();
43+
} else {
44+
// Focus first field wth an error if invalid. This matches the behavior
45+
// of native form validation, allowing the user to correct the error
46+
// and also announcing the problem to screen reader users.
47+
let foundFirst = false;
48+
for (const el of Array.from(formEl.elements) as Array<
49+
HTMLElement | HTMLInputElement
50+
>) {
51+
if (!('validity' in el) || el.validity.valid) {
52+
continue;
53+
}
54+
55+
if (!foundFirst) {
56+
el.focus();
57+
foundFirst = true;
58+
}
59+
60+
// If the user has focused an empty, required input field and
61+
// triggered a submission by pressing Enter, that will not trigger
62+
// a "change" event. Trigger this event to allow the input's "change"
63+
// handler to update its custom error.
64+
if (el.validity.valueMissing) {
65+
el.dispatchEvent(new Event('change'));
66+
}
67+
}
68+
}
69+
};
70+
return onSubmit;
71+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { useOrderedRows } from './hooks/use-ordered-rows';
77
export { useStableCallback } from './hooks/use-stable-callback';
88
export { useSyncedRef } from './hooks/use-synced-ref';
99
export { useToastMessages } from './hooks/use-toast-messages';
10+
export { useValidateOnSubmit } from './hooks/use-validate-on-submit';
1011
export type {
1112
ToastMessagesState,
1213
ToastMessageData,

0 commit comments

Comments
 (0)