diff --git a/src/components/date_picker/__snapshots__/date_picker.test.tsx.snap b/src/components/date_picker/__snapshots__/date_picker.test.tsx.snap index 75046e57fe6..85b96b58fb2 100644 --- a/src/components/date_picker/__snapshots__/date_picker.test.tsx.snap +++ b/src/components/date_picker/__snapshots__/date_picker.test.tsx.snap @@ -7,13 +7,10 @@ exports[`EuiDatePicker is rendered 1`] = ` - - - - - + + + `; @@ -869,20 +866,10 @@ exports[`EuiDatePicker localization inherits locale from context 1`] = ` exports[`EuiDatePicker popoverPlacement upRight is rendered 1`] = `
- - + + +
+ } + closePopover={[Function]} + display="block" + hasArrow={false} + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + panelRef={[Function]} > - - - - } - closePopover={[Function]} - display="block" - hasArrow={false} - isOpen={false} - ownFocus={false} - panelPaddingSize="none" - panelRef={[Function]} - > - +
+ + + + , + "ctr": 2, + "insertionPoint": undefined, + "isSpeedy": false, + "key": "css", + "nonce": undefined, + "prepend": undefined, + "tags": Array [ + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": undefined, + "name": "6tb1tw-euiPopover", + "next": undefined, + "styles": "position:relative;vertical-align:middle;max-inline-size: 100%;;;label:euiPopover;;;display:block;;", + "toString": [Function], + } + } + />
- - - - , - "ctr": 2, - "insertionPoint": undefined, - "isSpeedy": false, - "key": "css", - "nonce": undefined, - "prepend": undefined, - "tags": Array [ - , - , - ], - }, - } - } - isStringTag={true} - serialized={ - Object { - "map": undefined, - "name": "6tb1tw-euiPopover", - "next": undefined, - "styles": "position:relative;vertical-align:middle;max-inline-size: 100%;;;label:euiPopover;;;display:block;;", - "toString": [Function], - } - } - />
-
- + + + , + "ctr": 2, + "insertionPoint": undefined, + "isSpeedy": false, "key": "css", "nonce": undefined, - "registered": Object {}, - "sheet": StyleSheet { - "_alreadyInsertedOrderInsensitiveRule": true, - "_insertTag": [Function], - "before": null, - "container": - - - , - "ctr": 2, - "insertionPoint": undefined, - "isSpeedy": false, - "key": "css", - "nonce": undefined, - "prepend": undefined, - "tags": Array [ - , - , - ], - }, - } + "prepend": undefined, + "tags": Array [ + , + , + ], + }, } - isStringTag={true} - serialized={ - Object { - "map": undefined, - "name": "zih94u-render", - "next": undefined, - "styles": "display:block;;label:render;", - "toString": [Function], - } + } + isStringTag={true} + serialized={ + Object { + "map": undefined, + "name": "zih94u-render", + "next": undefined, + "styles": "display:block;;label:render;", + "toString": [Function], } - /> + } + /> +
-
- -
+
- - - - +
+ + +
@@ -224,6 +224,14 @@ exports[`EuiDatePickerRange isInvalid is rendered 1`] = ` />
+
+ +
@@ -232,7 +240,7 @@ exports[`EuiDatePickerRange isInvalid is rendered 1`] = ` > +
+ +
diff --git a/src/components/date_picker/_date_picker_range.scss b/src/components/date_picker/_date_picker_range.scss index d49f6996785..a70bb73f80b 100644 --- a/src/components/date_picker/_date_picker_range.scss +++ b/src/components/date_picker/_date_picker_range.scss @@ -60,6 +60,6 @@ align-items: center; } -.euiDatePickerRange--isInvalid .euiDatePickerRange__delimeter { +.euiDatePickerRange--isInvalid:not(.euiDatePickerRange--isDisabled):not(.euiDatePickerRange--readOnly) .euiDatePickerRange__delimeter { @include euiFormControlInvalidStyle; } diff --git a/src/components/date_picker/date_picker.test.tsx b/src/components/date_picker/date_picker.test.tsx index cb551118e72..de6c52eeee4 100644 --- a/src/components/date_picker/date_picker.test.tsx +++ b/src/components/date_picker/date_picker.test.tsx @@ -16,9 +16,7 @@ import { EuiContext } from '../context'; describe('EuiDatePicker', () => { test('is rendered', () => { - const component = shallow( - - ); + const component = shallow(); expect(component).toMatchSnapshot(); // snapshot of wrapping dom expect(component.find('ContextConsumer').shallow()).toMatchSnapshot(); // snapshot of DatePicker usage diff --git a/src/components/date_picker/date_picker.tsx b/src/components/date_picker/date_picker.tsx index 0d49faa201e..05e4db05844 100644 --- a/src/components/date_picker/date_picker.tsx +++ b/src/components/date_picker/date_picker.tsx @@ -6,16 +6,25 @@ * Side Public License, v 1. */ -import React, { Component, MouseEventHandler, Ref } from 'react'; +import React, { + FunctionComponent, + Component, + MouseEventHandler, + Ref, + useState, + useCallback, +} from 'react'; import classNames from 'classnames'; import { Moment } from 'moment'; // eslint-disable-line import/named -import { EuiFormControlLayout, EuiValidatableControl } from '../form'; +import { EuiFormControlLayout, useEuiValidatableControl } from '../form'; import { EuiFormControlLayoutIconsProps } from '../form/form_control_layout/form_control_layout_icons'; +import { getFormControlClassNameForIconCount } from '../form/form_control_layout/_num_icons'; +import { useCombinedRefs } from '../../services'; import { EuiI18nConsumer } from '../context'; -import { ApplyClassComponentDefaults, CommonProps } from '../common'; +import { CommonProps } from '../common'; import { PopoverAnchorPosition } from '../popover'; @@ -69,7 +78,7 @@ interface EuiExtendedDatePickerProps /** * ref for the ReactDatePicker instance */ - inputRef: Ref>; + inputRef?: Ref>; /** * Provides styling to the input when invalid @@ -119,156 +128,146 @@ interface EuiExtendedDatePickerProps popoverPlacement?: PopoverAnchorPosition; } -type _EuiDatePickerProps = CommonProps & EuiExtendedDatePickerProps; +export type EuiDatePickerProps = CommonProps & EuiExtendedDatePickerProps; -export type EuiDatePickerProps = ApplyClassComponentDefaults< - typeof EuiDatePicker ->; +export const EuiDatePicker: FunctionComponent = ({ + adjustDateOnChange = true, + calendarClassName, + className, + customInput, + dateFormat = euiDatePickerDefaultDateFormat, + dayClassName, + disabled, + excludeDates, + filterDate, + fullWidth = false, + iconType, + injectTimes, + inline, + inputRef, + isInvalid, + isLoading, + locale, + maxDate, + maxTime, + minDate, + minTime, + onChange, + onClear, + openToDate, + placeholder, + popperClassName, + popoverPlacement = 'downLeft', + selected, + shadow = true, + shouldCloseOnSelect = true, + showIcon = true, + showTimeSelect = false, + showTimeSelectOnly, + timeFormat = euiDatePickerDefaultTimeFormat, + utcOffset, + ...rest +}) => { + const classes = classNames('euiDatePicker', { + 'euiDatePicker--shadow': shadow, + 'euiDatePicker--inline': inline, + }); -export class EuiDatePicker extends Component<_EuiDatePickerProps> { - static defaultProps = { - adjustDateOnChange: true, - dateFormat: euiDatePickerDefaultDateFormat, - fullWidth: false, - inputRef: () => {}, - isLoading: false, - shadow: true, - shouldCloseOnSelect: true, - showIcon: true, - showTimeSelect: false, - timeFormat: euiDatePickerDefaultTimeFormat, - popoverPlacement: 'downLeft', - }; + const numIconsClass = getFormControlClassNameForIconCount({ + isInvalid, + isLoading, + }); - render() { - const { - adjustDateOnChange, - calendarClassName, - className, - customInput, - dateFormat, - dayClassName, - disabled, - excludeDates, - filterDate, - fullWidth, - iconType, - injectTimes, - inline, - inputRef, - isInvalid, - isLoading, - locale, - maxDate, - maxTime, - minDate, - minTime, - onChange, - onClear, - openToDate, - placeholder, - popperClassName, - popoverPlacement, - selected, - shadow, - shouldCloseOnSelect, - showIcon, - showTimeSelect, - showTimeSelectOnly, - timeFormat, - utcOffset, - ...rest - } = this.props; + const datePickerClasses = classNames( + 'euiDatePicker', + 'euiFieldText', + numIconsClass, + { + 'euiFieldText--fullWidth': fullWidth, + 'euiFieldText-isLoading': isLoading, + 'euiFieldText--withIcon': !inline && showIcon, + 'euiFieldText--isClearable': !inline && selected && onClear, + }, + className + ); - const classes = classNames('euiDatePicker', { - 'euiDatePicker--shadow': shadow, - 'euiDatePicker--inline': inline, - }); - - const datePickerClasses = classNames( - 'euiDatePicker', - 'euiFieldText', - { - 'euiFieldText--fullWidth': fullWidth, - 'euiFieldText-isLoading': isLoading, - 'euiFieldText--withIcon': !inline && showIcon, - 'euiFieldText--isClearable': !inline && selected && onClear, - 'euiFieldText-isInvalid': isInvalid, - }, - className - ); + let optionalIcon: EuiFormControlLayoutIconsProps['icon']; + if (inline || customInput || !showIcon) { + optionalIcon = undefined; + } else if (iconType) { + optionalIcon = iconType; + } else if (showTimeSelectOnly) { + optionalIcon = 'clock'; + } else { + optionalIcon = 'calendar'; + } - let optionalIcon: EuiFormControlLayoutIconsProps['icon']; - if (inline || customInput || !showIcon) { - optionalIcon = undefined; - } else if (iconType) { - optionalIcon = iconType; - } else if (showTimeSelectOnly) { - optionalIcon = 'clock'; - } else { - optionalIcon = 'calendar'; - } + // In case the consumer did not alter the default date format but wants + // to add the time select, we append the default time format + let fullDateFormat = dateFormat; + if (showTimeSelect && dateFormat === euiDatePickerDefaultDateFormat) { + fullDateFormat = `${dateFormat} ${timeFormat}`; + } - // In case the consumer did not alter the default date format but wants - // to add the time select, we append the default time format - let fullDateFormat = dateFormat; - if (showTimeSelect && dateFormat === euiDatePickerDefaultDateFormat) { - fullDateFormat = `${dateFormat} ${timeFormat}`; - } + // Set an internal ref on ReactDatePicker's `input` so we can set its :invalid state via useEuiValidatableControl + const [inputValidityRef, _setInputValidityRef] = useState(null); + const setInputValidityRef = useCallback((ref) => { + _setInputValidityRef(ref?.input); + }, []); + useEuiValidatableControl({ isInvalid, controlEl: inputValidityRef }); + const inputRefs = useCombinedRefs([inputRef, setInputValidityRef]); - return ( - - - - - {({ locale: contextLocale }) => { - return ( - - ); - }} - - - - - ); - } -} + return ( + + + + {({ locale: contextLocale }) => { + return ( + + ); + }} + + + + ); +}; diff --git a/src/components/date_picker/date_picker_range.tsx b/src/components/date_picker/date_picker_range.tsx index 40cf8e5856d..7c48bf777b8 100644 --- a/src/components/date_picker/date_picker_range.tsx +++ b/src/components/date_picker/date_picker_range.tsx @@ -48,7 +48,7 @@ export type EuiDatePickerRangeProps = CommonProps & { isCustom?: boolean; /** - * Will turn the range delimeter into an alert icon and pass through to each control + * Will color the range delimiter the `danger` color and pass through to each control */ isInvalid?: boolean; @@ -159,10 +159,7 @@ export const EuiDatePickerRange: FunctionComponent = ({ const delimiter = ( - + ); diff --git a/src/components/form/field_text/_field_text.scss b/src/components/form/field_text/_field_text.scss index 297a0f6a4ba..adcc9d5eba0 100644 --- a/src/components/form/field_text/_field_text.scss +++ b/src/components/form/field_text/_field_text.scss @@ -1,13 +1,6 @@ .euiFieldText { @include euiFormControlStyle; @include euiFormControlWithIcon($isIconOptional: true, $side: 'left'); - - /* Invalid state normally comes from :invalid, but several components - /* like EuiDatePicker need it toggled through an extra class. - */ - &.euiFieldText-isInvalid { - @include euiFormControlInvalidStyle; - } } .euiFieldText--withIcon.euiFieldText--compressed { diff --git a/src/components/form/validatable_control/index.ts b/src/components/form/validatable_control/index.ts index d927432035f..ff623923907 100644 --- a/src/components/form/validatable_control/index.ts +++ b/src/components/form/validatable_control/index.ts @@ -6,5 +6,11 @@ * Side Public License, v 1. */ -export type { EuiValidatableControlProps } from './validatable_control'; -export { EuiValidatableControl } from './validatable_control'; +export type { + EuiValidatableControlProps, + UseEuiValidatableControlProps, +} from './validatable_control'; +export { + EuiValidatableControl, + useEuiValidatableControl, +} from './validatable_control'; diff --git a/src/components/form/validatable_control/validatable_control.test.tsx b/src/components/form/validatable_control/validatable_control.test.tsx index 371321ffff8..494adb72575 100644 --- a/src/components/form/validatable_control/validatable_control.test.tsx +++ b/src/components/form/validatable_control/validatable_control.test.tsx @@ -8,8 +8,12 @@ import React from 'react'; import { render, mount } from 'enzyme'; +import { renderHook } from '@testing-library/react-hooks'; -import { EuiValidatableControl } from './validatable_control'; +import { + EuiValidatableControl, + useEuiValidatableControl, +} from './validatable_control'; describe('EuiValidatableControl', () => { test('is rendered', () => { @@ -154,3 +158,27 @@ describe('EuiValidatableControl', () => { }); }); }); + +describe('useEuiValidatableControl', () => { + const controlEl = document.createElement('input'); + + it('sets the validity of the passed control element', () => { + const { rerender } = renderHook(useEuiValidatableControl, { + initialProps: { isInvalid: true, controlEl }, + }); + expect(controlEl.validity.valid).toEqual(false); + + rerender({ isInvalid: false, controlEl }); + expect(controlEl.validity.valid).toEqual(true); + }); + + it('sets the `aria-invalid` of the passed control element', () => { + const { rerender } = renderHook(useEuiValidatableControl, { + initialProps: { isInvalid: true, controlEl }, + }); + expect(controlEl.getAttribute('aria-invalid')).toEqual('true'); + + rerender({ isInvalid: false, controlEl }); + expect(controlEl.getAttribute('aria-invalid')).toEqual('false'); + }); +}); diff --git a/src/components/form/validatable_control/validatable_control.tsx b/src/components/form/validatable_control/validatable_control.tsx index aebe7ce8301..d8439747a9b 100644 --- a/src/components/form/validatable_control/validatable_control.tsx +++ b/src/components/form/validatable_control/validatable_control.tsx @@ -33,11 +33,12 @@ function isMutableRef( return ref != null && ref.hasOwnProperty('current'); } +/** + * The `EuiValidatableControl` component should be used in scenarios where + * we can render the validated `` as its direct child. + */ export interface EuiValidatableControlProps { isInvalid?: boolean; - /** - * ReactNode to render as this component's content - */ children: ReactElementWithRef; } @@ -63,23 +64,60 @@ export const EuiValidatableControl: FunctionComponent< [childRef] ); + useSetControlValidity({ controlEl: control.current, isInvalid }); + + return cloneElement(child, { + ref: replacedRef, + 'aria-invalid': isInvalid, + }); +}; + +/** + * The `UseEuiValidatableControl` hook should be used in scenarios where + * we *cannot* control where the validated `` is rendered (e.g., ReactDatePicker) + * and instead need to access the input via a ref and pass the element in directly + */ +export interface UseEuiValidatableControlProps { + isInvalid?: boolean; + controlEl: HTMLInputElement | HTMLConstraintValidityElement | null; +} + +export const useEuiValidatableControl = ({ + isInvalid, + controlEl, +}: UseEuiValidatableControlProps) => { + useSetControlValidity({ controlEl, isInvalid }); + + useEffect(() => { + if (!controlEl) return; + + if (typeof isInvalid === 'boolean') { + controlEl.setAttribute('aria-invalid', String(isInvalid)); + } else { + controlEl.removeAttribute('aria-invalid'); + } + }, [isInvalid, controlEl]); +}; + +/** + * Internal `setCustomValidity` helper + */ +const useSetControlValidity = ({ + controlEl, + isInvalid, +}: UseEuiValidatableControlProps) => { useEffect(() => { if ( - control.current === null || - typeof control.current.setCustomValidity !== 'function' + controlEl == null || + typeof controlEl.setCustomValidity !== 'function' ) { - return; // jsdom doesn't polyfill this for the server-side + return; } if (isInvalid) { - control.current.setCustomValidity('Invalid'); + controlEl.setCustomValidity('Invalid'); } else { - control.current.setCustomValidity(''); + controlEl.setCustomValidity(''); } - }); - - return cloneElement(child, { - ref: replacedRef, - 'aria-invalid': isInvalid, - }); + }, [isInvalid, controlEl]); }; diff --git a/upcoming_changelogs/6677.md b/upcoming_changelogs/6677.md new file mode 100644 index 00000000000..8becfa54c9f --- /dev/null +++ b/upcoming_changelogs/6677.md @@ -0,0 +1 @@ +- Updated `EuiDatePicker` to display a warning icon and correctly set `aria-invalid` when `isInvalid` is passed