Skip to content

Commit

Permalink
[EuiFieldNumber] Fix browser invalid state not showing an icon or set…
Browse files Browse the repository at this point in the history
…ting `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
  • Loading branch information
cee-chen committed Apr 11, 2023
1 parent efa4089 commit 37c35ea
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ exports[`EuiFieldNumber is rendered 1`] = `
fullwidth="false"
icon="warning"
inputid="1"
isinvalid="false"
isloading="false"
>
<eui-validatable-control>
<input
aria-invalid="false"
aria-label="aria-label"
class="euiFieldNumber testClass1 testClass2 euiFieldNumber--withIcon"
data-test-subj="test subject string"
Expand All @@ -28,8 +30,10 @@ exports[`EuiFieldNumber is rendered 1`] = `
exports[`EuiFieldNumber props controlOnly is rendered 1`] = `
<eui-validatable-control>
<input
aria-invalid="false"
class="euiFieldNumber"
type="number"
value=""
/>
</eui-validatable-control>
`;
Expand All @@ -38,18 +42,21 @@ exports[`EuiFieldNumber props fullWidth is rendered 1`] = `
<eui-form-control-layout
compressed="false"
fullwidth="true"
isinvalid="false"
isloading="false"
>
<eui-validatable-control>
<input
aria-invalid="false"
class="euiFieldNumber euiFieldNumber--fullWidth"
type="number"
value=""
/>
</eui-validatable-control>
</eui-form-control-layout>
`;

exports[`EuiFieldNumber props isInvalid is rendered 1`] = `
exports[`EuiFieldNumber props isInvalid is rendered from a prop 1`] = `
<eui-form-control-layout
compressed="false"
fullwidth="false"
Expand All @@ -60,8 +67,10 @@ exports[`EuiFieldNumber props isInvalid is rendered 1`] = `
isinvalid="true"
>
<input
aria-invalid="true"
class="euiFieldNumber euiFormControlLayout--1icons"
type="number"
value=""
/>
</eui-validatable-control>
</eui-form-control-layout>
Expand All @@ -71,12 +80,15 @@ exports[`EuiFieldNumber props isLoading is rendered 1`] = `
<eui-form-control-layout
compressed="false"
fullwidth="false"
isinvalid="false"
isloading="true"
>
<eui-validatable-control>
<input
aria-invalid="false"
class="euiFieldNumber euiFormControlLayout--1icons euiFieldNumber-isLoading"
type="number"
value=""
/>
</eui-validatable-control>
</eui-form-control-layout>
Expand All @@ -86,14 +98,17 @@ exports[`EuiFieldNumber props readOnly is rendered 1`] = `
<eui-form-control-layout
compressed="false"
fullwidth="false"
isinvalid="false"
isloading="false"
readonly="true"
>
<eui-validatable-control>
<input
aria-invalid="false"
class="euiFieldNumber"
readonly=""
type="number"
value=""
/>
</eui-validatable-control>
</eui-form-control-layout>
Expand All @@ -103,10 +118,12 @@ exports[`EuiFieldNumber props value no initial value 1`] = `
<eui-form-control-layout
compressed="false"
fullwidth="false"
isinvalid="false"
isloading="false"
>
<eui-validatable-control>
<input
aria-invalid="false"
class="euiFieldNumber"
type="number"
value=""
Expand All @@ -119,10 +136,12 @@ exports[`EuiFieldNumber props value value is number 1`] = `
<eui-form-control-layout
compressed="false"
fullwidth="false"
isinvalid="false"
isloading="false"
>
<eui-validatable-control>
<input
aria-invalid="false"
class="euiFieldNumber"
type="number"
value="0"
Expand Down
55 changes: 55 additions & 0 deletions src/components/form/field_number/field_number.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

/// <reference types="cypress" />
/// <reference types="cypress-real-events" />
/// <reference types="../../../../cypress/support" />

import React from 'react';
import { EuiFieldNumber } from './field_number';

describe('EuiFieldNumber', () => {
describe('isNativelyInvalid', () => {
const checkIsValid = () => {
cy.get('[aria-invalid="true"]').should('not.exist');
cy.get('.euiFormControlLayoutIcons').should('not.exist');
};
const checkIsInvalid = () => {
cy.get('[aria-invalid="true"]').should('exist');
cy.get('.euiFormControlLayoutIcons').should('exist');
};

it('when the value is not a valid number', () => {
cy.mount(<EuiFieldNumber />);
checkIsValid();
cy.get('input').click().realType('-.');
checkIsInvalid();
});

it('sets invalid state when the value is less than the passed min', () => {
cy.mount(<EuiFieldNumber min={0} />);
checkIsValid();
cy.get('input').click().type('-10');
checkIsInvalid();
});

it('sets invalid state when the value is greater than the passed max', () => {
cy.mount(<EuiFieldNumber max={100} />);
checkIsValid();
cy.get('input').click().type('101');
checkIsInvalid();
});

it('sets invalid state when the value is not a valid step', () => {
cy.mount(<EuiFieldNumber step={3} />);
checkIsValid();
cy.get('input').click().type('2');
checkIsInvalid();
});
});
});
53 changes: 31 additions & 22 deletions src/components/form/field_number/field_number.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import React from 'react';
import { render } from 'enzyme';
import { render } from '../../../test/rtl';
import { requiredProps } from '../../../test/required_props';

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

describe('EuiFieldNumber', () => {
test('is rendered', () => {
const component = render(
const { container } = render(
<EuiFieldNumber
id="1"
name="elastic"
Expand All @@ -40,68 +40,77 @@ describe('EuiFieldNumber', () => {
/>
);

expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

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

expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

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

expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

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

expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

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

expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

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

expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

test('inputRef', () => {
const inputRef = jest.fn();
const { container } = render(<EuiFieldNumber inputRef={inputRef} />);

expect(inputRef).toHaveBeenCalledTimes(1);
expect(container.querySelector('input[type="number"]')).toBe(
inputRef.mock.calls[0][0]
);
});

describe('value', () => {
test('value is number', () => {
const component = render(
const { container } = render(
<EuiFieldNumber value={0} onChange={() => {}} />
);
expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});

test('no initial value', () => {
const component = render(
const { container } = render(
<EuiFieldNumber value={''} onChange={() => {}} />
);
expect(component).toMatchSnapshot();
expect(container.firstChild).toMatchSnapshot();
});
});
});

describe('inherits', () => {
test('fullWidth from <EuiForm />', () => {
const component = render(
const { container } = render(
<EuiForm fullWidth>
<EuiFieldNumber />
</EuiForm>
);
const control = container.querySelector('.euiFieldNumber')!;

if (
!component.find('.euiFieldNumber').hasClass('euiFieldNumber--fullWidth')
) {
if (!control.classList.contains('euiFieldNumber--fullWidth')) {
throw new Error(
'expected EuiFieldNumber to inherit fullWidth from EuiForm'
);
Expand Down
49 changes: 40 additions & 9 deletions src/components/form/field_number/field_number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,27 @@
* Side Public License, v 1.
*/

import React, { InputHTMLAttributes, Ref, FunctionComponent } from 'react';
import React, {
InputHTMLAttributes,
Ref,
FunctionComponent,
useState,
useRef,
useCallback,
} from 'react';
import { CommonProps } from '../../common';
import classNames from 'classnames';

import { useCombinedRefs } from '../../../services';
import { IconType } from '../../icon';

import { EuiValidatableControl } from '../validatable_control';
import {
EuiFormControlLayout,
EuiFormControlLayoutProps,
} from '../form_control_layout';

import { EuiValidatableControl } from '../validatable_control';

import { IconType } from '../../icon';
import { useFormContext } from '../eui_form_context';
import { getFormControlClassNameForIconCount } from '../form_control_layout/_num_icons';
import { useFormContext } from '../eui_form_context';

export type EuiFieldNumberProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
Expand Down Expand Up @@ -96,13 +103,35 @@ export const EuiFieldNumber: FunctionComponent<EuiFieldNumberProps> = (
inputRef,
readOnly,
controlOnly,
onKeyDown: _onKeyDown,
...rest
} = props;

// Attempt to determine additional invalid state. The native number input
// will set :invalid state automatically, but we need to also set
// `aria-invalid` as well as display an icon. We also want to *not* set this on
// EuiValidatableControl, in order to not override custom validity messages
const [isNativelyInvalid, setIsNativelyInvalid] = useState(false);
const validityRef = useRef<HTMLInputElement | null>(null);
const setRefs = useCombinedRefs([validityRef, inputRef]);

// Note that we can't use hook into `onChange` because browsers don't emit change events
// for invalid values - see https://github.com/facebook/react/issues/16554
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
_onKeyDown?.(e);
// Wait a beat before checking validity - we can't use `e.target` as it's stale
requestAnimationFrame(() => {
setIsNativelyInvalid(!validityRef.current!.validity.valid);
});
},
[_onKeyDown]
);

const numIconsClass = controlOnly
? false
: getFormControlClassNameForIconCount({
isInvalid,
isInvalid: isInvalid || isNativelyInvalid,
isLoading,
});

Expand All @@ -126,7 +155,9 @@ export const EuiFieldNumber: FunctionComponent<EuiFieldNumberProps> = (
placeholder={placeholder}
readOnly={readOnly}
className={classes}
ref={inputRef}
ref={setRefs}
onKeyDown={onKeyDown}
aria-invalid={isInvalid || isNativelyInvalid}
{...rest}
/>
</EuiValidatableControl>
Expand All @@ -141,7 +172,7 @@ export const EuiFieldNumber: FunctionComponent<EuiFieldNumberProps> = (
icon={icon}
fullWidth={fullWidth}
isLoading={isLoading}
isInvalid={isInvalid}
isInvalid={isInvalid || isNativelyInvalid}
compressed={compressed}
readOnly={readOnly}
prepend={prepend}
Expand Down
Loading

0 comments on commit 37c35ea

Please sign in to comment.