Skip to content

Commit 5923074

Browse files
committed
Add Input.error prop
This sets a custom validation error for the input component which is communicated to the browser via `HTMLInputElement.setCustomValidity`. This, along with any native validation constraints such as `required`, will prevent form submission until they are resolved.
1 parent 03792d7 commit 5923074

File tree

5 files changed

+123
-5
lines changed

5 files changed

+123
-5
lines changed

src/components/input/Input.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import classnames from 'classnames';
22
import type { JSX } from 'preact';
33

4-
import type { PresentationalProps } from '../../types';
4+
import { useSyncedRef } from '../../hooks/use-synced-ref';
5+
import { useValidationError } from '../../hooks/use-validation-error';
6+
import type { FormControlProps, PresentationalProps } from '../../types';
57
import { downcastRef } from '../../util/typing';
68
import { inputGroupStyles } from './InputGroup';
79

@@ -26,9 +28,8 @@ export function inputStyles({ classes, feedback }: InputStylesOptions) {
2628
);
2729
}
2830

29-
type ComponentProps = {
31+
type ComponentProps = FormControlProps & {
3032
type?: 'text' | 'email' | 'search' | 'number' | 'password' | 'url';
31-
feedback?: 'error' | 'warning';
3233
};
3334

3435
export type InputProps = PresentationalProps &
@@ -42,6 +43,7 @@ export default function Input({
4243
elementRef,
4344
type = 'text',
4445
classes,
46+
error,
4547
feedback,
4648

4749
...htmlAttributes
@@ -52,11 +54,21 @@ export default function Input({
5254
);
5355
}
5456

57+
const inputRef = downcastRef<HTMLElement | undefined, HTMLInputElement>(
58+
elementRef,
59+
);
60+
const ref = useSyncedRef<HTMLInputElement>(inputRef);
61+
62+
if (error) {
63+
feedback = 'error';
64+
}
65+
useValidationError(ref, error);
66+
5567
return (
5668
<input
5769
data-component="Input"
5870
{...htmlAttributes}
59-
ref={downcastRef(elementRef)}
71+
ref={ref}
6072
type={type}
6173
className={inputStyles({ classes, feedback })}
6274
aria-invalid={feedback === 'error'}

src/components/input/test/Input-test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ describe('Input', () => {
2323
console.warn.restore();
2424
});
2525

26+
[
27+
{
28+
error: undefined,
29+
validationMessage: '',
30+
invalid: false,
31+
},
32+
{
33+
error: 'Not a valid URL',
34+
validationMessage: 'Not a valid URL',
35+
invalid: true,
36+
},
37+
].forEach(({ error, validationMessage, invalid }) => {
38+
it('should set custom validation error if `error` prop is provided', () => {
39+
const wrapper = mount(<Input aria-label="Test" error={error} />);
40+
const input = wrapper.find('input');
41+
assert.equal(input.getDOMNode().validationMessage, validationMessage);
42+
assert.equal(input.prop('aria-invalid'), invalid);
43+
});
44+
});
45+
2646
[
2747
{ feedback: undefined, expectedInvalid: false },
2848
{ feedback: 'error', expectedInvalid: true },

src/hooks/use-validation-error.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { RefObject } from 'preact';
2+
import { useLayoutEffect } from 'preact/hooks';
3+
4+
export type InputLike = {
5+
setCustomValidity(message: string): void;
6+
};
7+
8+
/**
9+
* Sync custom validation error messages to the browser's native validation
10+
* state.
11+
*
12+
* @param ref - An `HTMLInputElement` or other element that supports the
13+
* Constraint Validation API
14+
* @param error - The current error or undefined if the field input is valid
15+
*/
16+
export function useValidationError(
17+
ref: RefObject<InputLike | undefined>,
18+
error?: string,
19+
) {
20+
// Sync errors to native form validation API. This will prevent submission
21+
// of the form until the error is resolved.
22+
useLayoutEffect(() => {
23+
ref.current?.setCustomValidity(error ?? '');
24+
}, [error, ref]);
25+
}

src/pattern-library/components/patterns/input/InputPage.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function InputPage() {
2929
</Library.Pattern>
3030

3131
<Library.Pattern title="Working with Inputs">
32-
<Library.Example title="Accessibility">
32+
<Library.Example title="Labels">
3333
<p>
3434
Hypothesis does not currently have a design pattern for labeling
3535
text inputs. However, for accessibility, it is critical that an{' '}
@@ -61,6 +61,26 @@ export default function InputPage() {
6161
</div>
6262
</Library.Demo>
6363
</Library.Example>
64+
<Library.Example title="Validation errors">
65+
<p>
66+
Validation errors can be triggered as a result of standard HTML
67+
attributes such as <code>required</code> or a custom error set
68+
using the <code>error</code> prop. Errors set using{' '}
69+
<code>error</code> are synced to the browser via{' '}
70+
<code>HTMLInputElement.setCustomValidity</code>. This allows the
71+
browser to alert the user when they try to submit the containing
72+
form.
73+
</p>
74+
<p>
75+
If the input has custom validation checks, they should be
76+
performed in the element&apos;s <code>onChange</code> handler.
77+
</p>
78+
<p>
79+
If the form is using custom UI to present validation errors,
80+
inputs must link to the element displaying their validation error
81+
using <code>aria-describedby</code>.
82+
</p>
83+
</Library.Example>
6484
</Library.Pattern>
6585

6686
<Library.Pattern title="Component API">
@@ -69,6 +89,23 @@ export default function InputPage() {
6989
presentational component props
7090
</Library.Link>
7191
.
92+
<Library.Example title="error">
93+
<Library.Info>
94+
<Library.InfoItem label="description">
95+
Set <code>error</code> to indicate a validation error. This
96+
implicitly sets{' '}
97+
<code>
98+
feedback={'"'}error{'"'}
99+
</code>
100+
. In addition to visually and semantically indicating the error
101+
state, this will set a custom validation error on the input
102+
using <code>HTMLInputElement.setCustomValidity</code>.
103+
</Library.InfoItem>
104+
<Library.InfoItem label="type">
105+
<code>string</code>
106+
</Library.InfoItem>
107+
</Library.Info>
108+
</Library.Example>
72109
<Library.Example title="feedback">
73110
<Library.Info>
74111
<Library.InfoItem label="description">

src/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,30 @@ export type PresentationalProps = {
1111
elementRef?: Ref<HTMLElement | undefined>;
1212
};
1313

14+
/**
15+
* Common props for form controls.
16+
*/
17+
export type FormControlProps = {
18+
/**
19+
* The current validation error.
20+
*
21+
* If set, this will override `feedback` and set it to `error`. The validation
22+
* error will be synced to the browser's native validation state via
23+
* {@link HTMLInputElement.setCustomValidity}. This will prevent submission
24+
* of the containing form.
25+
*/
26+
error?: string;
27+
28+
/**
29+
* Set the visual and semantic state (`aria-invalid`) of the control to
30+
* indicate an error.
31+
*
32+
* Unlike {@link FormControlProps.error} this does not set a native validation
33+
* error and as such, it won't prevent a containing form from being submitted.
34+
*/
35+
feedback?: 'error' | 'warning';
36+
};
37+
1438
/**
1539
* Props common to components that are opinionated compositions of other
1640
* components.

0 commit comments

Comments
 (0)