From 5f3a97cc33045b710a25d83629bad31cbfb9f5b2 Mon Sep 17 00:00:00 2001
From: Cee Chen <549407+cee-chen@users.noreply.github.com>
Date: Tue, 11 Apr 2023 13:28:02 -0700
Subject: [PATCH] [EuiRange/EuiDualRange] Add alert icon when `isInvalid` and
`showInput` (#6704)
* [EuiFormControlLayoutDelimited] nit: fix `delimiter` spelling
* [EuiFormControlLayoutDelimited] Change default delimiter to an icon instead of text
- to more correctly matches the current Figma designs
+ add a screen reader label
* [EuiFormControlLayoutDelimited] Add better `isInvalid` styling
- color arrow + extend line under arrow and icons
+ fix background colors of icons
* [EuiDualRange] Fix buggy styling when `isInvalid` and `showInput="inputWithPopover"`
- the input was rendering the padding offset for the invalid icon, without actually rendering said icons (due to `controlOnly` prop)
* [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
* [EuiRange/EuiDualRange] Improve UX of input widths on EuiRange/EuiDualRange
- account for `invalid` icon (which now automatically displays for out of range inputs)
- dynamically adjust width based on # of characters in actual input
- adjust width affordances based on `compressed`
- width changes are especially a readability improvement in Firefox
* changelog
* Revert horizontal padding on delimited icons
- after playing more with date range picker as well as a broader variety of delimited inputs, this change was too specific to EuiDualRange
* snapshot
---
.../quick_select_popover.test.tsx.snap | 1 +
.../__snapshots__/field_number.test.tsx.snap | 21 +++-
.../form/field_number/field_number.spec.tsx | 55 +++++++++
.../form/field_number/field_number.test.tsx | 53 ++++----
.../form/field_number/field_number.tsx | 57 +++++++--
src/components/form/field_text/field_text.tsx | 10 +-
...orm_control_layout_delimited.test.tsx.snap | 13 +-
.../_form_control_layout_delimited.scss | 37 ++++--
.../form_control_layout_delimited.tsx | 53 ++++++--
.../__snapshots__/dual_range.test.tsx.snap | 34 ++++--
.../range/__snapshots__/range.test.tsx.snap | 5 +-
.../__snapshots__/range_input.test.tsx.snap | 21 ++++
src/components/form/range/dual_range.tsx | 4 +-
src/components/form/range/range.tsx | 2 -
.../form/range/range_input.test.tsx | 114 ++++++++++++++++++
src/components/form/range/range_input.tsx | 65 ++++++++--
.../validatable_control.test.tsx | 14 +++
.../validatable_control.tsx | 2 +-
upcoming_changelogs/6704.md | 3 +
19 files changed, 469 insertions(+), 95 deletions(-)
create mode 100644 src/components/form/field_number/field_number.spec.tsx
create mode 100644 src/components/form/range/__snapshots__/range_input.test.tsx.snap
create mode 100644 src/components/form/range/range_input.test.tsx
create mode 100644 upcoming_changelogs/6704.md
diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select_popover.test.tsx.snap b/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select_popover.test.tsx.snap
index 9c8a08945f7..09b2b778b60 100644
--- a/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select_popover.test.tsx.snap
+++ b/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select_popover.test.tsx.snap
@@ -134,6 +134,7 @@ exports[`EuiQuickSelectPanels customQuickSelectPanels should render custom panel
>
`;
@@ -38,18 +42,21 @@ exports[`EuiFieldNumber props fullWidth is rendered 1`] = `
`;
-exports[`EuiFieldNumber props isInvalid is rendered 1`] = `
+exports[`EuiFieldNumber props isInvalid is rendered from a prop 1`] = `
@@ -71,12 +80,15 @@ exports[`EuiFieldNumber props isLoading is rendered 1`] = `
@@ -86,14 +98,17 @@ exports[`EuiFieldNumber props readOnly is rendered 1`] = `
@@ -103,10 +118,12 @@ exports[`EuiFieldNumber props value no initial value 1`] = `
+///
+///
+
+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();
+ checkIsValid();
+ cy.get('input').click().realType('-.');
+ checkIsInvalid();
+ });
+
+ it('sets invalid state when the value is less than the passed min', () => {
+ cy.mount();
+ checkIsValid();
+ cy.get('input').click().type('-10');
+ checkIsInvalid();
+ });
+
+ it('sets invalid state when the value is greater than the passed max', () => {
+ cy.mount();
+ checkIsValid();
+ cy.get('input').click().type('101');
+ checkIsInvalid();
+ });
+
+ it('sets invalid state when the value is not a valid step', () => {
+ cy.mount();
+ checkIsValid();
+ cy.get('input').click().type('2');
+ checkIsInvalid();
+ });
+ });
+});
diff --git a/src/components/form/field_number/field_number.test.tsx b/src/components/form/field_number/field_number.test.tsx
index a734c4be1cd..5ce42c0715e 100644
--- a/src/components/form/field_number/field_number.test.tsx
+++ b/src/components/form/field_number/field_number.test.tsx
@@ -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';
@@ -26,7 +26,7 @@ jest.mock('../validatable_control', () => ({
describe('EuiFieldNumber', () => {
test('is rendered', () => {
- const component = render(
+ const { container } = render(
{
/>
);
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
describe('props', () => {
- test('isInvalid is rendered', () => {
- const component = render();
+ test('isInvalid is rendered from a prop', () => {
+ const { container } = render();
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
test('fullWidth is rendered', () => {
- const component = render();
+ const { container } = render();
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
test('isLoading is rendered', () => {
- const component = render();
+ const { container } = render();
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
test('readOnly is rendered', () => {
- const component = render();
+ const { container } = render();
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
test('controlOnly is rendered', () => {
- const component = render();
+ const { container } = render();
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ test('inputRef', () => {
+ const inputRef = jest.fn();
+ const { container } = render();
+
+ 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(
{}} />
);
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
test('no initial value', () => {
- const component = render(
+ const { container } = render(
{}} />
);
- expect(component).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
});
});
describe('inherits', () => {
test('fullWidth from ', () => {
- const component = render(
+ const { container } = render(
);
+ 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'
);
diff --git a/src/components/form/field_number/field_number.tsx b/src/components/form/field_number/field_number.tsx
index 777e287f379..24f9c21a28f 100644
--- a/src/components/form/field_number/field_number.tsx
+++ b/src/components/form/field_number/field_number.tsx
@@ -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,
@@ -96,13 +103,37 @@ export const EuiFieldNumber: FunctionComponent = (
inputRef,
readOnly,
controlOnly,
+ onKeyDown: _onKeyDown,
...rest
} = props;
- const numIconsClass = getFormControlClassNameForIconCount({
- isInvalid,
- isLoading,
- });
+ // 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(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) => {
+ _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 || isNativelyInvalid,
+ isLoading,
+ });
const classes = classNames('euiFieldNumber', className, numIconsClass, {
'euiFieldNumber--withIcon': icon,
@@ -124,7 +155,9 @@ export const EuiFieldNumber: FunctionComponent = (
placeholder={placeholder}
readOnly={readOnly}
className={classes}
- ref={inputRef}
+ ref={setRefs}
+ onKeyDown={onKeyDown}
+ aria-invalid={isInvalid || isNativelyInvalid}
{...rest}
/>
@@ -139,7 +172,7 @@ export const EuiFieldNumber: FunctionComponent = (
icon={icon}
fullWidth={fullWidth}
isLoading={isLoading}
- isInvalid={isInvalid}
+ isInvalid={isInvalid || isNativelyInvalid}
compressed={compressed}
readOnly={readOnly}
prepend={prepend}
diff --git a/src/components/form/field_text/field_text.tsx b/src/components/form/field_text/field_text.tsx
index 5c75564de71..3a40234d5ed 100644
--- a/src/components/form/field_text/field_text.tsx
+++ b/src/components/form/field_text/field_text.tsx
@@ -78,10 +78,12 @@ export const EuiFieldText: FunctionComponent = (props) => {
...rest
} = props;
- const numIconsClass = getFormControlClassNameForIconCount({
- isInvalid,
- isLoading,
- });
+ const numIconsClass = controlOnly
+ ? false
+ : getFormControlClassNameForIconCount({
+ isInvalid,
+ isLoading,
+ });
const classes = classNames('euiFieldText', className, numIconsClass, {
'euiFieldText--withIcon': icon,
diff --git a/src/components/form/form_control_layout/__snapshots__/form_control_layout_delimited.test.tsx.snap b/src/components/form/form_control_layout/__snapshots__/form_control_layout_delimited.test.tsx.snap
index bac34682d28..3f396138333 100644
--- a/src/components/form/form_control_layout/__snapshots__/form_control_layout_delimited.test.tsx.snap
+++ b/src/components/form/form_control_layout/__snapshots__/form_control_layout_delimited.test.tsx.snap
@@ -15,9 +15,14 @@ exports[`EuiFormControlLayoutDelimited is rendered 1`] = `
start
- →
+
+ to
+
+
diff --git a/src/components/form/form_control_layout/_form_control_layout_delimited.scss b/src/components/form/form_control_layout/_form_control_layout_delimited.scss
index 75c5a942d52..635e1087baf 100644
--- a/src/components/form/form_control_layout/_form_control_layout_delimited.scss
+++ b/src/components/form/form_control_layout/_form_control_layout_delimited.scss
@@ -5,7 +5,8 @@
align-items: stretch;
padding: 1px; /* 1 */
- .euiFormControlLayoutDelimited__delimeter {
+ .euiFormControlLayoutDelimited__delimiter,
+ .euiFormControlLayoutIcons {
background-color: $euiFormBackgroundColor;
}
@@ -28,8 +29,7 @@
}
.euiFormControlLayoutIcons {
- padding-left: $euiFormControlCompressedPadding;
- padding-right: $euiFormControlCompressedPadding;
+ padding-inline: $euiFormControlCompressedPadding;
}
}
@@ -44,7 +44,8 @@
&[class*='-isDisabled'] {
@include euiFormControlDisabledStyle;
- .euiFormControlLayoutDelimited__delimeter {
+ .euiFormControlLayoutDelimited__delimiter,
+ .euiFormControlLayoutIcons {
background-color: $euiFormBackgroundDisabledColor;
}
}
@@ -54,7 +55,8 @@
@include euiFormControlReadOnlyStyle;
input,
- .euiFormControlLayoutDelimited__delimeter {
+ .euiFormControlLayoutDelimited__delimiter,
+ .euiFormControlLayoutIcons {
background-color: $euiFormBackgroundReadOnlyColor;
}
}
@@ -63,13 +65,18 @@
// Absolutely positioning the icons doesn't work because they
// overlay only one of controls making the layout unbalanced
position: static; // Overrider absolute
- padding-left: $euiFormControlPadding;
- padding-right: $euiFormControlPadding;
+ padding-inline: $euiFormControlPadding;
+ align-self: stretch;
+ flex-grow: 0;
&:not(.euiFormControlLayoutIcons--right) {
order: -1;
}
}
+
+ &--isInvalid .euiFormControlLayoutIcons {
+ @include euiFormControlInvalidStyle;
+ }
}
.euiFormControlLayoutDelimited__input {
@@ -82,10 +89,14 @@
min-width: 0; // Fixes FF
}
-.euiFormControlLayoutDelimited__delimeter {
- // stylelint-disable-next-line declaration-no-important
- line-height: 1 !important; // Override EuiText line-height
- flex: 0 0 auto;
- padding-left: $euiFormControlPadding / 2;
- padding-right: $euiFormControlPadding / 2;
+.euiFormControlLayoutDelimited__delimiter {
+ align-self: stretch;
+ flex-grow: 0;
+ display: flex;
+ align-items: center;
+ line-height: 1; // Override EuiText line-height
+
+ &--isInvalid {
+ @include euiFormControlInvalidStyle;
+ }
}
diff --git a/src/components/form/form_control_layout/form_control_layout_delimited.tsx b/src/components/form/form_control_layout/form_control_layout_delimited.tsx
index 683df9f655f..43ec8a79a20 100644
--- a/src/components/form/form_control_layout/form_control_layout_delimited.tsx
+++ b/src/components/form/form_control_layout/form_control_layout_delimited.tsx
@@ -14,7 +14,10 @@ import React, {
} from 'react';
import classNames from 'classnames';
+import { useEuiI18n } from '../../i18n';
+import { EuiIcon } from '../../icon';
import { EuiText } from '../../text';
+
import {
EuiFormControlLayout,
EuiFormControlLayoutProps,
@@ -42,32 +45,60 @@ export type EuiFormControlLayoutDelimitedProps = Partial<
export const EuiFormControlLayoutDelimited: FunctionComponent = ({
startControl,
endControl,
- delimiter = '→',
+ delimiter,
className,
...rest
}) => {
- const classes = classNames('euiFormControlLayoutDelimited', className);
+ const { isInvalid, isDisabled, readOnly } = rest;
+ const showInvalidState = isInvalid && !isDisabled && !readOnly;
+
+ const classes = classNames('euiFormControlLayoutDelimited', className, {
+ 'euiFormControlLayoutDelimited--isInvalid': showInvalidState,
+ });
return (
{addClassesToControl(startControl)}
-
- {delimiter}
-
+
{addClassesToControl(endControl)}
);
};
-function addClassesToControl(control: ReactElement) {
+const addClassesToControl = (control: ReactElement) => {
return cloneElement(control, {
className: classNames(
control.props.className,
'euiFormControlLayoutDelimited__input'
),
});
-}
+};
+
+const EuiFormControlDelimiter = ({
+ delimiter,
+ isInvalid,
+}: {
+ delimiter?: ReactNode;
+ isInvalid?: boolean;
+}) => {
+ const classes = classNames('euiFormControlLayoutDelimited__delimiter', {
+ 'euiFormControlLayoutDelimited__delimiter--isInvalid': isInvalid,
+ });
+ const color = isInvalid ? 'danger' : 'subdued';
+
+ const defaultAriaLabel = useEuiI18n(
+ 'euiFormControlLayoutDelimited.delimiterLabel',
+ 'to'
+ );
+
+ return (
+
+ {delimiter ?? (
+
+ )}
+
+ );
+};
diff --git a/src/components/form/range/__snapshots__/dual_range.test.tsx.snap b/src/components/form/range/__snapshots__/dual_range.test.tsx.snap
index 1a703bac6fb..759b2fa8f74 100644
--- a/src/components/form/range/__snapshots__/dual_range.test.tsx.snap
+++ b/src/components/form/range/__snapshots__/dual_range.test.tsx.snap
@@ -63,13 +63,14 @@ exports[`EuiDualRange input props can be applied to min and max inputs 1`] = `
class="euiFormControlLayout__childrenWrapper"
>
@@ -112,13 +113,14 @@ exports[`EuiDualRange input props can be applied to min and max inputs 1`] = `
class="euiFormControlLayout__childrenWrapper"
>
@@ -321,13 +323,14 @@ exports[`EuiDualRange props inputs should render 1`] = `
class="euiFormControlLayout__childrenWrapper"
>
@@ -372,13 +375,14 @@ exports[`EuiDualRange props inputs should render 1`] = `
class="euiFormControlLayout__childrenWrapper"
>
@@ -528,6 +532,7 @@ exports[`EuiDualRange props loading should display when showInput="inputWithPopo
class="euiFormControlLayout__childrenWrapper"
>
- →
+
+ to
+
- →
+
+ to
+
@@ -354,6 +355,7 @@ exports[`EuiRange props loading should display when showInput="inputWithPopover"
class="euiFormControlLayout__childrenWrapper"
>
+
+
+
+
+`;
diff --git a/src/components/form/range/dual_range.tsx b/src/components/form/range/dual_range.tsx
index 0d1592827d8..fdcf835382f 100644
--- a/src/components/form/range/dual_range.tsx
+++ b/src/components/form/range/dual_range.tsx
@@ -466,7 +466,6 @@ export class EuiDualRangeClass extends Component<
const { id } = this.state;
- const digitTolerance = Math.max(String(min).length, String(max).length);
const showInputOnly = showInput === 'inputWithPopover';
const canShowDropdown = showInputOnly && !readOnly && !disabled;
@@ -479,7 +478,6 @@ export class EuiDualRangeClass extends Component<
aria-label={this.props['aria-label']}
{...minInputProps}
// Non-overridable props
- digitTolerance={digitTolerance}
side="min"
min={min}
max={Number(this.upperValue)}
@@ -510,7 +508,6 @@ export class EuiDualRangeClass extends Component<
aria-label={this.props['aria-label']}
{...maxInputProps}
// Non-overridable props
- digitTolerance={digitTolerance}
side="max"
min={Number(this.lowerValue)}
max={max}
@@ -755,6 +752,7 @@ export class EuiDualRangeClass extends Component<
append={append}
prepend={prepend}
isLoading={isLoading}
+ isInvalid={isInvalid}
/>
}
fullWidth={fullWidth}
diff --git a/src/components/form/range/range.tsx b/src/components/form/range/range.tsx
index 5f1f4f30d6a..622ed3b8aca 100644
--- a/src/components/form/range/range.tsx
+++ b/src/components/form/range/range.tsx
@@ -147,7 +147,6 @@ export class EuiRangeClass extends Component<
const { id } = this.state;
- const digitTolerance = Math.max(String(min).length, String(max).length);
const showInputOnly = showInput === 'inputWithPopover';
const canShowDropdown = showInputOnly && !readOnly && !disabled;
@@ -156,7 +155,6 @@ export class EuiRangeClass extends Component<
id={id}
min={min}
max={max}
- digitTolerance={digitTolerance}
step={step}
value={value}
readOnly={readOnly}
diff --git a/src/components/form/range/range_input.test.tsx b/src/components/form/range/range_input.test.tsx
new file mode 100644
index 00000000000..17516aa71e2
--- /dev/null
+++ b/src/components/form/range/range_input.test.tsx
@@ -0,0 +1,114 @@
+/*
+ * 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.
+ */
+
+import React from 'react';
+import { render } from '../../../test/rtl';
+import { shouldRenderCustomStyles } from '../../../test/internal';
+
+import { EuiRangeInput } from './range_input';
+
+const requiredProps = {
+ min: 0,
+ max: 100,
+ value: 0,
+ onChange: () => {},
+};
+
+describe('EuiRangeInput', () => {
+ shouldRenderCustomStyles();
+
+ it('renders', () => {
+ const { container } = render();
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ describe('widthStyle', () => {
+ it('does not set a width style if the `autoSize` is set to false', () => {
+ const { container } = render(
+
+ );
+ const widthStyle = container
+ .querySelector('.euiRangeInput')
+ ?.getAttribute('style');
+
+ expect(widthStyle).toBeFalsy();
+ });
+
+ it('increases input character width dynamically based on value', () => {
+ const { rerender, container } = render(
+
+ );
+ const getWidthStyle = () =>
+ container.querySelector('.euiRangeInput')?.getAttribute('style');
+
+ expect(getWidthStyle()).toMatchInlineSnapshot(
+ '"inline-size: calc(12px + 1ch + 2em + 0px);"'
+ );
+
+ rerender();
+ expect(getWidthStyle()).toMatchInlineSnapshot(
+ '"inline-size: calc(12px + 2ch + 2em + 0px);"'
+ );
+
+ rerender();
+ expect(getWidthStyle()).toMatchInlineSnapshot(
+ '"inline-size: calc(12px + 3ch + 2em + 0px);"'
+ );
+
+ rerender();
+ expect(getWidthStyle()).toMatchInlineSnapshot(
+ '"inline-size: calc(12px + 4ch + 2em + 22px);"'
+ );
+
+ // Should not go above 4 characters in width
+ rerender();
+ expect(getWidthStyle()).toMatchInlineSnapshot(
+ '"inline-size: calc(12px + 4ch + 2em + 22px);"'
+ );
+ });
+
+ test('compressed', () => {
+ const { container } = render(
+
+ );
+ const widthStyle = container
+ .querySelector('.euiRangeInput')
+ ?.getAttribute('style');
+
+ expect(widthStyle).toMatchInlineSnapshot(
+ '"inline-size: calc(8px + 1ch + 2em + 0px);"'
+ );
+ });
+
+ test('invalid', () => {
+ const { container } = render(
+
+ );
+ const widthStyle = container
+ .querySelector('.euiRangeInput')
+ ?.getAttribute('style');
+
+ expect(widthStyle).toMatchInlineSnapshot(
+ '"inline-size: calc(12px + 1ch + 2em + 22px);"'
+ );
+ });
+
+ test('invalid + compressed', () => {
+ const { container } = render(
+
+ );
+ const widthStyle = container
+ .querySelector('.euiRangeInput')
+ ?.getAttribute('style');
+
+ expect(widthStyle).toMatchInlineSnapshot(
+ '"inline-size: calc(8px + 1ch + 2em + 18px);"'
+ );
+ });
+ });
+});
diff --git a/src/components/form/range/range_input.tsx b/src/components/form/range/range_input.tsx
index 621aa5b1370..ad3d41271d2 100644
--- a/src/components/form/range/range_input.tsx
+++ b/src/components/form/range/range_input.tsx
@@ -6,10 +6,17 @@
* Side Public License, v 1.
*/
-import React, { FunctionComponent, useMemo } from 'react';
+import React, {
+ FunctionComponent,
+ useState,
+ useEffect,
+ useMemo,
+ useRef,
+} from 'react';
-import { useEuiTheme } from '../../../services';
+import { useEuiTheme, useCombinedRefs } from '../../../services';
import { logicalStyles } from '../../../global_styling';
+import { euiFormVariables } from '../form.styles';
import { EuiFieldNumber, EuiFieldNumberProps } from '../field_number';
import type { _SingleRangeValue, _SharedRangeInputSide } from './types';
@@ -20,7 +27,6 @@ export interface EuiRangeInputProps
Omit<_SingleRangeValue, 'onChange'>,
_SharedRangeInputSide {
autoSize?: boolean;
- digitTolerance: number;
}
export const EuiRangeInput: FunctionComponent = ({
@@ -28,29 +34,60 @@ export const EuiRangeInput: FunctionComponent = ({
max,
step,
value,
+ inputRef,
+ isInvalid,
disabled,
compressed,
onChange,
name,
side = 'max',
- digitTolerance,
fullWidth,
autoSize = true,
...rest
}) => {
- // Chrome will properly size the input based on the max value, but FF does not.
- // Calculate the width of the input based on highest number of characters.
- // Add 2 to accommodate for input stepper
- const widthStyle = useMemo(() => {
- return autoSize
- ? logicalStyles({ width: `${digitTolerance / 1.25 + 2}em` })
- : {};
- }, [autoSize, digitTolerance]);
-
const euiTheme = useEuiTheme();
const styles = euiRangeInputStyles(euiTheme);
const cssStyles = [styles.euiRangeInput];
+ // Determine whether an invalid icon is showing, which can come from
+ // the underlying EuiFieldNumber's native :invalid state
+ const [hasInvalidIcon, setHasInvalidIcon] = useState(isInvalid);
+ const validityRef = useRef(null);
+ const setRefs = useCombinedRefs([validityRef, inputRef]);
+
+ useEffect(() => {
+ const isNativelyInvalid = !validityRef.current?.validity.valid;
+ setHasInvalidIcon(isNativelyInvalid || isInvalid);
+ }, [value, isInvalid]);
+
+ // Calculate the auto size width of the input
+ const widthStyle = useMemo(() => {
+ if (!autoSize) return undefined;
+
+ // Calculate the number of characters to show (dynamic based on user input)
+ // Uses the min/max char length as a max, then add an extra UX buffer of 1
+ const maxChars = Math.max(String(min).length, String(max).length) + 1;
+ const inputCharWidth = Math.min(String(value).length, maxChars);
+
+ // Calculate the form padding based on `compressed` state
+ const { controlPadding, controlCompressedPadding } = euiFormVariables(
+ euiTheme
+ );
+ const inputPadding = compressed ? controlCompressedPadding : controlPadding;
+
+ // Calculate the invalid icon (if being displayed), also based on `compressed` state
+ const invalidIconWidth = hasInvalidIcon
+ ? euiTheme.euiTheme.base * (compressed ? 1.125 : 1.375) // TODO: DRY this out once EuiFormControlLayoutIcons is converted to Emotion
+ : 0;
+
+ // Guesstimate a width for the stepper. Note that it's a little wider in FF than it is in Chrome
+ const stepperWidth = 2;
+
+ return logicalStyles({
+ width: `calc(${inputPadding} + ${inputCharWidth}ch + ${stepperWidth}em + ${invalidIconWidth}px)`,
+ });
+ }, [autoSize, euiTheme, compressed, hasInvalidIcon, min, max, value]);
+
return (
= ({
max={Number(max)}
step={step}
value={value === '' ? '' : Number(value)}
+ inputRef={setRefs}
+ isInvalid={isInvalid}
disabled={disabled}
compressed={compressed}
onChange={onChange}
diff --git a/src/components/form/validatable_control/validatable_control.test.tsx b/src/components/form/validatable_control/validatable_control.test.tsx
index 494adb72575..0c1972d461c 100644
--- a/src/components/form/validatable_control/validatable_control.test.tsx
+++ b/src/components/form/validatable_control/validatable_control.test.tsx
@@ -26,6 +26,20 @@ describe('EuiValidatableControl', () => {
expect(component).toMatchSnapshot();
});
+ test('aria-invalid allows falling back to prop set on the child input', () => {
+ const component = render(
+
+
+
+ );
+
+ expect(component).toMatchInlineSnapshot(`
+
+ `);
+ });
+
describe('ref management', () => {
it('calls a ref function', () => {
const ref = jest.fn();
diff --git a/src/components/form/validatable_control/validatable_control.tsx b/src/components/form/validatable_control/validatable_control.tsx
index d8439747a9b..1ae8932911f 100644
--- a/src/components/form/validatable_control/validatable_control.tsx
+++ b/src/components/form/validatable_control/validatable_control.tsx
@@ -68,7 +68,7 @@ export const EuiValidatableControl: FunctionComponent<
return cloneElement(child, {
ref: replacedRef,
- 'aria-invalid': isInvalid,
+ 'aria-invalid': isInvalid || child.props['aria-invalid'],
});
};
diff --git a/upcoming_changelogs/6704.md b/upcoming_changelogs/6704.md
new file mode 100644
index 00000000000..3af1488d1bd
--- /dev/null
+++ b/upcoming_changelogs/6704.md
@@ -0,0 +1,3 @@
+- Updated `EuiFieldNumber` to detect native browser invalid state and show an invalid icon
+- Improved the input widths of `EuiRange` and `EuiDualRange` when `showInput={true}` to account for invalid icons
+- Improved the `isInvalid` styling of `EuiDualRange` when `showInput="inputWithPopover"`