Skip to content

Commit 37c35ea

Browse files
committed
[EuiFieldNumber] Fix browser invalid state not showing an icon or setting aria-invalid
Browsers natively set their own custom `validity` based on min/max/value/step/etc - we should hook into these and extend them (as opposed to overriding them) + switch Jest tests from Enzyme to RTL while here
1 parent efa4089 commit 37c35ea

6 files changed

Lines changed: 161 additions & 33 deletions

File tree

src/components/form/field_number/__snapshots__/field_number.test.tsx.snap

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ exports[`EuiFieldNumber is rendered 1`] = `
66
fullwidth="false"
77
icon="warning"
88
inputid="1"
9+
isinvalid="false"
910
isloading="false"
1011
>
1112
<eui-validatable-control>
1213
<input
14+
aria-invalid="false"
1315
aria-label="aria-label"
1416
class="euiFieldNumber testClass1 testClass2 euiFieldNumber--withIcon"
1517
data-test-subj="test subject string"
@@ -28,8 +30,10 @@ exports[`EuiFieldNumber is rendered 1`] = `
2830
exports[`EuiFieldNumber props controlOnly is rendered 1`] = `
2931
<eui-validatable-control>
3032
<input
33+
aria-invalid="false"
3134
class="euiFieldNumber"
3235
type="number"
36+
value=""
3337
/>
3438
</eui-validatable-control>
3539
`;
@@ -38,18 +42,21 @@ exports[`EuiFieldNumber props fullWidth is rendered 1`] = `
3842
<eui-form-control-layout
3943
compressed="false"
4044
fullwidth="true"
45+
isinvalid="false"
4146
isloading="false"
4247
>
4348
<eui-validatable-control>
4449
<input
50+
aria-invalid="false"
4551
class="euiFieldNumber euiFieldNumber--fullWidth"
4652
type="number"
53+
value=""
4754
/>
4855
</eui-validatable-control>
4956
</eui-form-control-layout>
5057
`;
5158

52-
exports[`EuiFieldNumber props isInvalid is rendered 1`] = `
59+
exports[`EuiFieldNumber props isInvalid is rendered from a prop 1`] = `
5360
<eui-form-control-layout
5461
compressed="false"
5562
fullwidth="false"
@@ -60,8 +67,10 @@ exports[`EuiFieldNumber props isInvalid is rendered 1`] = `
6067
isinvalid="true"
6168
>
6269
<input
70+
aria-invalid="true"
6371
class="euiFieldNumber euiFormControlLayout--1icons"
6472
type="number"
73+
value=""
6574
/>
6675
</eui-validatable-control>
6776
</eui-form-control-layout>
@@ -71,12 +80,15 @@ exports[`EuiFieldNumber props isLoading is rendered 1`] = `
7180
<eui-form-control-layout
7281
compressed="false"
7382
fullwidth="false"
83+
isinvalid="false"
7484
isloading="true"
7585
>
7686
<eui-validatable-control>
7787
<input
88+
aria-invalid="false"
7889
class="euiFieldNumber euiFormControlLayout--1icons euiFieldNumber-isLoading"
7990
type="number"
91+
value=""
8092
/>
8193
</eui-validatable-control>
8294
</eui-form-control-layout>
@@ -86,14 +98,17 @@ exports[`EuiFieldNumber props readOnly is rendered 1`] = `
8698
<eui-form-control-layout
8799
compressed="false"
88100
fullwidth="false"
101+
isinvalid="false"
89102
isloading="false"
90103
readonly="true"
91104
>
92105
<eui-validatable-control>
93106
<input
107+
aria-invalid="false"
94108
class="euiFieldNumber"
95109
readonly=""
96110
type="number"
111+
value=""
97112
/>
98113
</eui-validatable-control>
99114
</eui-form-control-layout>
@@ -103,10 +118,12 @@ exports[`EuiFieldNumber props value no initial value 1`] = `
103118
<eui-form-control-layout
104119
compressed="false"
105120
fullwidth="false"
121+
isinvalid="false"
106122
isloading="false"
107123
>
108124
<eui-validatable-control>
109125
<input
126+
aria-invalid="false"
110127
class="euiFieldNumber"
111128
type="number"
112129
value=""
@@ -119,10 +136,12 @@ exports[`EuiFieldNumber props value value is number 1`] = `
119136
<eui-form-control-layout
120137
compressed="false"
121138
fullwidth="false"
139+
isinvalid="false"
122140
isloading="false"
123141
>
124142
<eui-validatable-control>
125143
<input
144+
aria-invalid="false"
126145
class="euiFieldNumber"
127146
type="number"
128147
value="0"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
/// <reference types="cypress" />
10+
/// <reference types="cypress-real-events" />
11+
/// <reference types="../../../../cypress/support" />
12+
13+
import React from 'react';
14+
import { EuiFieldNumber } from './field_number';
15+
16+
describe('EuiFieldNumber', () => {
17+
describe('isNativelyInvalid', () => {
18+
const checkIsValid = () => {
19+
cy.get('[aria-invalid="true"]').should('not.exist');
20+
cy.get('.euiFormControlLayoutIcons').should('not.exist');
21+
};
22+
const checkIsInvalid = () => {
23+
cy.get('[aria-invalid="true"]').should('exist');
24+
cy.get('.euiFormControlLayoutIcons').should('exist');
25+
};
26+
27+
it('when the value is not a valid number', () => {
28+
cy.mount(<EuiFieldNumber />);
29+
checkIsValid();
30+
cy.get('input').click().realType('-.');
31+
checkIsInvalid();
32+
});
33+
34+
it('sets invalid state when the value is less than the passed min', () => {
35+
cy.mount(<EuiFieldNumber min={0} />);
36+
checkIsValid();
37+
cy.get('input').click().type('-10');
38+
checkIsInvalid();
39+
});
40+
41+
it('sets invalid state when the value is greater than the passed max', () => {
42+
cy.mount(<EuiFieldNumber max={100} />);
43+
checkIsValid();
44+
cy.get('input').click().type('101');
45+
checkIsInvalid();
46+
});
47+
48+
it('sets invalid state when the value is not a valid step', () => {
49+
cy.mount(<EuiFieldNumber step={3} />);
50+
checkIsValid();
51+
cy.get('input').click().type('2');
52+
checkIsInvalid();
53+
});
54+
});
55+
});

src/components/form/field_number/field_number.test.tsx

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import React from 'react';
10-
import { render } from 'enzyme';
10+
import { render } from '../../../test/rtl';
1111
import { requiredProps } from '../../../test/required_props';
1212

1313
import { EuiForm } from '../form';
@@ -26,7 +26,7 @@ jest.mock('../validatable_control', () => ({
2626

2727
describe('EuiFieldNumber', () => {
2828
test('is rendered', () => {
29-
const component = render(
29+
const { container } = render(
3030
<EuiFieldNumber
3131
id="1"
3232
name="elastic"
@@ -40,68 +40,77 @@ describe('EuiFieldNumber', () => {
4040
/>
4141
);
4242

43-
expect(component).toMatchSnapshot();
43+
expect(container.firstChild).toMatchSnapshot();
4444
});
4545

4646
describe('props', () => {
47-
test('isInvalid is rendered', () => {
48-
const component = render(<EuiFieldNumber isInvalid />);
47+
test('isInvalid is rendered from a prop', () => {
48+
const { container } = render(<EuiFieldNumber isInvalid />);
4949

50-
expect(component).toMatchSnapshot();
50+
expect(container.firstChild).toMatchSnapshot();
5151
});
5252

5353
test('fullWidth is rendered', () => {
54-
const component = render(<EuiFieldNumber fullWidth />);
54+
const { container } = render(<EuiFieldNumber fullWidth />);
5555

56-
expect(component).toMatchSnapshot();
56+
expect(container.firstChild).toMatchSnapshot();
5757
});
5858

5959
test('isLoading is rendered', () => {
60-
const component = render(<EuiFieldNumber isLoading />);
60+
const { container } = render(<EuiFieldNumber isLoading />);
6161

62-
expect(component).toMatchSnapshot();
62+
expect(container.firstChild).toMatchSnapshot();
6363
});
6464

6565
test('readOnly is rendered', () => {
66-
const component = render(<EuiFieldNumber readOnly />);
66+
const { container } = render(<EuiFieldNumber readOnly />);
6767

68-
expect(component).toMatchSnapshot();
68+
expect(container.firstChild).toMatchSnapshot();
6969
});
7070

7171
test('controlOnly is rendered', () => {
72-
const component = render(<EuiFieldNumber controlOnly />);
72+
const { container } = render(<EuiFieldNumber controlOnly />);
7373

74-
expect(component).toMatchSnapshot();
74+
expect(container.firstChild).toMatchSnapshot();
75+
});
76+
77+
test('inputRef', () => {
78+
const inputRef = jest.fn();
79+
const { container } = render(<EuiFieldNumber inputRef={inputRef} />);
80+
81+
expect(inputRef).toHaveBeenCalledTimes(1);
82+
expect(container.querySelector('input[type="number"]')).toBe(
83+
inputRef.mock.calls[0][0]
84+
);
7585
});
7686

7787
describe('value', () => {
7888
test('value is number', () => {
79-
const component = render(
89+
const { container } = render(
8090
<EuiFieldNumber value={0} onChange={() => {}} />
8191
);
82-
expect(component).toMatchSnapshot();
92+
expect(container.firstChild).toMatchSnapshot();
8393
});
8494

8595
test('no initial value', () => {
86-
const component = render(
96+
const { container } = render(
8797
<EuiFieldNumber value={''} onChange={() => {}} />
8898
);
89-
expect(component).toMatchSnapshot();
99+
expect(container.firstChild).toMatchSnapshot();
90100
});
91101
});
92102
});
93103

94104
describe('inherits', () => {
95105
test('fullWidth from <EuiForm />', () => {
96-
const component = render(
106+
const { container } = render(
97107
<EuiForm fullWidth>
98108
<EuiFieldNumber />
99109
</EuiForm>
100110
);
111+
const control = container.querySelector('.euiFieldNumber')!;
101112

102-
if (
103-
!component.find('.euiFieldNumber').hasClass('euiFieldNumber--fullWidth')
104-
) {
113+
if (!control.classList.contains('euiFieldNumber--fullWidth')) {
105114
throw new Error(
106115
'expected EuiFieldNumber to inherit fullWidth from EuiForm'
107116
);

src/components/form/field_number/field_number.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,27 @@
66
* Side Public License, v 1.
77
*/
88

9-
import React, { InputHTMLAttributes, Ref, FunctionComponent } from 'react';
9+
import React, {
10+
InputHTMLAttributes,
11+
Ref,
12+
FunctionComponent,
13+
useState,
14+
useRef,
15+
useCallback,
16+
} from 'react';
1017
import { CommonProps } from '../../common';
1118
import classNames from 'classnames';
1219

20+
import { useCombinedRefs } from '../../../services';
21+
import { IconType } from '../../icon';
22+
23+
import { EuiValidatableControl } from '../validatable_control';
1324
import {
1425
EuiFormControlLayout,
1526
EuiFormControlLayoutProps,
1627
} from '../form_control_layout';
17-
18-
import { EuiValidatableControl } from '../validatable_control';
19-
20-
import { IconType } from '../../icon';
21-
import { useFormContext } from '../eui_form_context';
2228
import { getFormControlClassNameForIconCount } from '../form_control_layout/_num_icons';
29+
import { useFormContext } from '../eui_form_context';
2330

2431
export type EuiFieldNumberProps = Omit<
2532
InputHTMLAttributes<HTMLInputElement>,
@@ -96,13 +103,35 @@ export const EuiFieldNumber: FunctionComponent<EuiFieldNumberProps> = (
96103
inputRef,
97104
readOnly,
98105
controlOnly,
106+
onKeyDown: _onKeyDown,
99107
...rest
100108
} = props;
101109

110+
// Attempt to determine additional invalid state. The native number input
111+
// will set :invalid state automatically, but we need to also set
112+
// `aria-invalid` as well as display an icon. We also want to *not* set this on
113+
// EuiValidatableControl, in order to not override custom validity messages
114+
const [isNativelyInvalid, setIsNativelyInvalid] = useState(false);
115+
const validityRef = useRef<HTMLInputElement | null>(null);
116+
const setRefs = useCombinedRefs([validityRef, inputRef]);
117+
118+
// Note that we can't use hook into `onChange` because browsers don't emit change events
119+
// for invalid values - see https://github.com/facebook/react/issues/16554
120+
const onKeyDown = useCallback(
121+
(e: React.KeyboardEvent<HTMLInputElement>) => {
122+
_onKeyDown?.(e);
123+
// Wait a beat before checking validity - we can't use `e.target` as it's stale
124+
requestAnimationFrame(() => {
125+
setIsNativelyInvalid(!validityRef.current!.validity.valid);
126+
});
127+
},
128+
[_onKeyDown]
129+
);
130+
102131
const numIconsClass = controlOnly
103132
? false
104133
: getFormControlClassNameForIconCount({
105-
isInvalid,
134+
isInvalid: isInvalid || isNativelyInvalid,
106135
isLoading,
107136
});
108137

@@ -126,7 +155,9 @@ export const EuiFieldNumber: FunctionComponent<EuiFieldNumberProps> = (
126155
placeholder={placeholder}
127156
readOnly={readOnly}
128157
className={classes}
129-
ref={inputRef}
158+
ref={setRefs}
159+
onKeyDown={onKeyDown}
160+
aria-invalid={isInvalid || isNativelyInvalid}
130161
{...rest}
131162
/>
132163
</EuiValidatableControl>
@@ -141,7 +172,7 @@ export const EuiFieldNumber: FunctionComponent<EuiFieldNumberProps> = (
141172
icon={icon}
142173
fullWidth={fullWidth}
143174
isLoading={isLoading}
144-
isInvalid={isInvalid}
175+
isInvalid={isInvalid || isNativelyInvalid}
145176
compressed={compressed}
146177
readOnly={readOnly}
147178
prepend={prepend}

0 commit comments

Comments
 (0)