diff --git a/components/_util/statusUtils.tsx b/components/_util/statusUtils.tsx new file mode 100644 index 0000000000..fb9171604b --- /dev/null +++ b/components/_util/statusUtils.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'; +import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled'; +import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; +import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; +import classNames from 'classnames'; +import { ValidateStatus } from '../form/FormItem'; +import { tuple } from './type'; + +const InputStatuses = tuple('warning', 'error', ''); +export type InputStatus = typeof InputStatuses[number]; + +const iconMap = { + success: CheckCircleFilled, + warning: ExclamationCircleFilled, + error: CloseCircleFilled, + validating: LoadingOutlined, +}; + +export const getFeedbackIcon = (prefixCls: string, status?: ValidateStatus) => { + const IconNode = status && iconMap[status]; + return IconNode ? ( + + + + ) : null; +}; + +export function getStatusClassNames( + prefixCls: string, + status?: ValidateStatus, + hasFeedback?: boolean, +) { + return classNames({ + [`${prefixCls}-status-success`]: status === 'success', + [`${prefixCls}-status-warning`]: status === 'warning', + [`${prefixCls}-status-error`]: status === 'error', + [`${prefixCls}-status-validating`]: status === 'validating', + [`${prefixCls}-has-feedback`]: hasFeedback, + }); +} diff --git a/components/config-provider/__tests__/__snapshots__/components.test.js.snap b/components/config-provider/__tests__/__snapshots__/components.test.js.snap index 7817ae165f..fc2202c243 100644 --- a/components/config-provider/__tests__/__snapshots__/components.test.js.snap +++ b/components/config-provider/__tests__/__snapshots__/components.test.js.snap @@ -13367,7 +13367,7 @@ exports[`ConfigProvider components Form configProvider 1`] = ` class="config-form-item-control-input-content" > @@ -13405,7 +13405,7 @@ exports[`ConfigProvider components Form configProvider componentSize large 1`] = class="config-form-item-control-input-content" > @@ -13443,7 +13443,7 @@ exports[`ConfigProvider components Form configProvider componentSize middle 1`] class="config-form-item-control-input-content" > @@ -13481,7 +13481,7 @@ exports[`ConfigProvider components Form configProvider virtual and dropdownMatch class="ant-form-item-control-input-content" > @@ -13519,7 +13519,7 @@ exports[`ConfigProvider components Form normal 1`] = ` class="ant-form-item-control-input-content" > @@ -13557,7 +13557,7 @@ exports[`ConfigProvider components Form prefixCls 1`] = ` class="prefix-Form-item-control-input-content" > diff --git a/components/form/FormItem.tsx b/components/form/FormItem.tsx index d04a33850f..d879c2cab0 100644 --- a/components/form/FormItem.tsx +++ b/components/form/FormItem.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; import classNames from 'classnames'; import { Field, FormInstance, FieldContext, ListContext } from 'rc-field-form'; import { FieldProps } from 'rc-field-form/lib/Field'; @@ -12,7 +12,12 @@ import { tuple } from '../_util/type'; import devWarning from '../_util/devWarning'; import FormItemLabel, { FormItemLabelProps, LabelTooltipType } from './FormItemLabel'; import FormItemInput, { FormItemInputProps } from './FormItemInput'; -import { FormContext, NoStyleItemContext } from './context'; +import { + FormContext, + FormItemStatusContext, + NoStyleItemContext, + FormItemStatusContextProps, +} from './context'; import { toArray, getFieldId } from './util'; import { cloneElement, isValidElement } from '../_util/reactNode'; import useFrameState from './hooks/useFrameState'; @@ -199,6 +204,28 @@ function FormItem(props: FormItemProps): React.ReactElemen // ===================== Children Ref ===================== const getItemRef = useItemRef(); + // ======================== Status ======================== + let mergedValidateStatus: ValidateStatus = ''; + if (validateStatus !== undefined) { + mergedValidateStatus = validateStatus; + } else if (meta?.validating) { + mergedValidateStatus = 'validating'; + } else if (debounceErrors.length) { + mergedValidateStatus = 'error'; + } else if (debounceWarnings.length) { + mergedValidateStatus = 'warning'; + } else if (meta?.touched) { + mergedValidateStatus = 'success'; + } + + const formItemStatusContext = useMemo( + () => ({ + status: mergedValidateStatus, + hasFeedback, + }), + [mergedValidateStatus, hasFeedback], + ); + // ======================== Render ======================== function renderLayout( baseChildren: React.ReactNode, @@ -208,19 +235,6 @@ function FormItem(props: FormItemProps): React.ReactElemen if (noStyle && !hidden) { return baseChildren; } - // ======================== Status ======================== - let mergedValidateStatus: ValidateStatus = ''; - if (validateStatus !== undefined) { - mergedValidateStatus = validateStatus; - } else if (meta?.validating) { - mergedValidateStatus = 'validating'; - } else if (debounceErrors.length) { - mergedValidateStatus = 'error'; - } else if (debounceWarnings.length) { - mergedValidateStatus = 'warning'; - } else if (meta?.touched) { - mergedValidateStatus = 'success'; - } const itemClassName = { [`${prefixCls}-item`]: true, @@ -281,11 +295,12 @@ function FormItem(props: FormItemProps): React.ReactElemen warnings={debounceWarnings} prefixCls={prefixCls} status={mergedValidateStatus} - validateStatus={mergedValidateStatus} help={help} > - {baseChildren} + + {baseChildren} + diff --git a/components/form/FormItemInput.tsx b/components/form/FormItemInput.tsx index e67a9f502f..4b4c2c8641 100644 --- a/components/form/FormItemInput.tsx +++ b/components/form/FormItemInput.tsx @@ -1,10 +1,5 @@ import * as React from 'react'; import classNames from 'classnames'; -import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; -import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; -import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'; -import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled'; - import Col, { ColProps } from '../grid/col'; import { ValidateStatus } from './FormItem'; import { FormContext, FormItemPrefixContext } from './context'; @@ -15,8 +10,6 @@ interface FormItemInputMiscProps { children: React.ReactNode; errors: React.ReactNode[]; warnings: React.ReactNode[]; - hasFeedback?: boolean; - validateStatus?: ValidateStatus; /** @private Internal Usage, do not use in any of your production. */ _internalItemRender?: { mark: string; @@ -38,13 +31,6 @@ export interface FormItemInputProps { help?: React.ReactNode; } -const iconMap: { [key: string]: any } = { - success: CheckCircleFilled, - warning: ExclamationCircleFilled, - error: CloseCircleFilled, - validating: LoadingOutlined, -}; - const FormItemInput: React.FC = props => { const { prefixCls, @@ -53,9 +39,7 @@ const FormItemInput: React.FC = pro children, errors, warnings, - hasFeedback, _internalItemRender: formItemRender, - validateStatus, extra, help, } = props; @@ -67,15 +51,6 @@ const FormItemInput: React.FC = pro const className = classNames(`${baseClassName}-control`, mergedWrapperCol.className); - // Should provides additional icon if `hasFeedback` - const IconNode = validateStatus && iconMap[validateStatus]; - const icon = - hasFeedback && IconNode ? ( - - - - ) : null; - // Pass to sub FormItem should not with col info const subFormContext = React.useMemo(() => ({ ...formContext }), [formContext]); delete subFormContext.labelCol; @@ -84,7 +59,6 @@ const FormItemInput: React.FC = pro const inputDom = ( {children} - {icon} ); const formItemContext = React.useMemo(() => ({ prefixCls, status }), [prefixCls, status]); diff --git a/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap index 52de5e42a3..3d9f26f35f 100644 --- a/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -1477,7 +1477,7 @@ exports[`renders ./components/form/demo/disabled-input-debug.md extend context c class="ant-form-item-control-input-content" > - - - - + - - + + + + + + + + - + - - - - + - - + + + + + + + + - + @@ -16227,38 +16243,46 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc > - - - - + - - + + + + + + + + - + @@ -16283,38 +16307,46 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc > - - - - + - - + + + + + + + + - + - - - - - - - @@ -18402,29 +18411,6 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc - - - - - - - @@ -18477,7 +18463,9 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc + > + I'm Select + - - - - - - - @@ -18680,7 +18645,9 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc + > + I'm Cascader + - - - - - - - + + + + Warning + + + + + + + + + + + + I'm TreeSelect + + + + + + + + + + + + + + + + + + + + + + + + + xx + + + + + + + + + + + + + + + + + + + + + + + + + Need to be checked + + + + - - - - - - - @@ -20185,7 +20276,7 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc class="ant-form-item-control-input-content" > @@ -20216,32 +20307,32 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc /> + + + + + + + - - - - - - - @@ -20268,7 +20359,7 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc class="ant-form-item-control-input-content" > + + + + + + + - - - - - - - @@ -20355,7 +20446,7 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc class="ant-form-item-control-input-content" > + + + + + + + - - - - - - - @@ -20473,6 +20564,97 @@ exports[`renders ./components/form/demo/validate-static.md extend context correc + + + + Fail + + + + + + + + + + + + + + + + + + + + + + + + + + + Should have something + + + + `; diff --git a/components/form/__tests__/__snapshots__/demo.test.js.snap b/components/form/__tests__/__snapshots__/demo.test.js.snap index 97492fefaf..df7d5f6f0e 100644 --- a/components/form/__tests__/__snapshots__/demo.test.js.snap +++ b/components/form/__tests__/__snapshots__/demo.test.js.snap @@ -1197,7 +1197,7 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] = class="ant-form-item-control-input-content" > - - - - + - - + + + + + + + + - + - - - - + - - + + + + + + + + - + @@ -7452,38 +7468,46 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` > - - - - + - - + + + + + + + + - + @@ -7508,38 +7532,46 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` > - - - - + - - + + + + + + + + - + - - - - - - - @@ -7708,29 +7717,6 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` - - - - - - - @@ -7783,7 +7769,9 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` + > + I'm Select + - - - - - - - @@ -7887,7 +7852,9 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` + > + I'm Cascader + - - - - - - - + + + + Warning + + + + + + + + + + + + I'm TreeSelect + + + + + + + + + + + + + + + Need to be checked + + + + - - - - - - - @@ -8253,7 +8267,7 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` class="ant-form-item-control-input-content" > @@ -8284,32 +8298,32 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` /> + + + + + + + - - - - - - - @@ -8336,7 +8350,7 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` class="ant-form-item-control-input-content" > + + + + + + + - - - - - - - @@ -8423,7 +8437,7 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` class="ant-form-item-control-input-content" > + + + + + + + - - - - - - - @@ -8541,6 +8555,97 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = ` + + + + Fail + + + + + + + + + + + + + + + + + + + + + + + + + + + Should have something + + + + `; diff --git a/components/form/context.tsx b/components/form/context.tsx index 73801a8b1b..8ba6d2ac6b 100644 --- a/components/form/context.tsx +++ b/components/form/context.tsx @@ -50,3 +50,10 @@ export interface FormItemPrefixContextProps { export const FormItemPrefixContext = React.createContext({ prefixCls: '', }); + +export interface FormItemStatusContextProps { + status?: ValidateStatus; + hasFeedback?: boolean; +} + +export const FormItemStatusContext = React.createContext({}); diff --git a/components/form/demo/validate-static.md b/components/form/demo/validate-static.md index 4d82dbadf5..8ab7c38f89 100644 --- a/components/form/demo/validate-static.md +++ b/components/form/demo/validate-static.md @@ -23,7 +23,17 @@ We provide properties like `validateStatus` `help` `hasFeedback` to customize yo ```tsx import { SmileOutlined } from '@ant-design/icons'; -import { Form, Input, DatePicker, TimePicker, Select, Cascader, InputNumber, Mentions } from 'antd'; +import { + Form, + Input, + DatePicker, + TimePicker, + Select, + Cascader, + InputNumber, + Mentions, + TreeSelect, +} from 'antd'; const { Option } = Select; @@ -87,7 +97,7 @@ ReactDOM.render( - + Option 1 Option 2 Option 3 @@ -100,7 +110,15 @@ ReactDOM.render( validateStatus="validating" help="The information is being validated..." > - + + + + + @@ -140,6 +158,10 @@ ReactDOM.render( + + + + , mountNode, ); diff --git a/components/form/demo/without-form-create.md b/components/form/demo/without-form-create.md index 758c0ee78f..33efcfe52e 100644 --- a/components/form/demo/without-form-create.md +++ b/components/form/demo/without-form-create.md @@ -19,9 +19,10 @@ import { Form, InputNumber } from 'antd'; type ValidateStatus = Parameters[0]['validateStatus']; -function validatePrimeNumber( - number: number, -): { validateStatus: ValidateStatus; errorMsg: string | null } { +function validatePrimeNumber(number: number): { + validateStatus: ValidateStatus; + errorMsg: string | null; +} { if (number === 11) { return { validateStatus: 'success', diff --git a/components/form/style/mixin.less b/components/form/style/mixin.less index 603212a275..60ab5646f5 100644 --- a/components/form/style/mixin.less +++ b/components/form/style/mixin.less @@ -10,40 +10,10 @@ .@{ant-prefix}-form-item-split { color: @text-color; } - // 输入框的不同校验状态 - :not(.@{ant-prefix}-input-disabled):not(.@{ant-prefix}-input-borderless).@{ant-prefix}-input, - :not(.@{ant-prefix}-input-affix-wrapper-disabled):not(.@{ant-prefix}-input-affix-wrapper-borderless).@{ant-prefix}-input-affix-wrapper, - :not(.@{ant-prefix}-input-number-affix-wrapper-disabled):not(.@{ant-prefix}-input-number-affix-wrapper-borderless).@{ant-prefix}-input-number-affix-wrapper { - &, - &:hover { - background-color: @background-color; - border-color: @border-color; - } - - &:focus, - &-focused { - .active(@border-color, @hoverBorderColor, @outlineColor); - } - } .@{ant-prefix}-calendar-picker-open .@{ant-prefix}-calendar-picker-input { .active(@border-color, @hoverBorderColor, @outlineColor); } - - .@{ant-prefix}-input-prefix, - .@{ant-prefix}-input-number-prefix { - color: @text-color; - } - - .@{ant-prefix}-input-group-addon, - .@{ant-prefix}-input-number-group-addon { - color: @text-color; - border-color: @border-color; - } - - .has-feedback { - color: @text-color; - } } // Reset form styles diff --git a/components/form/style/status.less b/components/form/style/status.less index e16cf5753b..1a53d97a96 100644 --- a/components/form/style/status.less +++ b/components/form/style/status.less @@ -24,287 +24,19 @@ } &-has-feedback { - // ========================= Input ========================= - .@{ant-prefix}-input { - padding-right: 24px; - } - // https://github.com/ant-design/ant-design/issues/19884 - .@{ant-prefix}-input-affix-wrapper { - .@{ant-prefix}-input-suffix { - padding-right: 18px; - } - } - - // Fix issue: https://github.com/ant-design/ant-design/issues/7854 - .@{ant-prefix}-input-search:not(.@{ant-prefix}-input-search-enter-button) { - .@{ant-prefix}-input-suffix { - right: 28px; - } - } - // ======================== Switch ========================= .@{ant-prefix}-switch { margin: 2px 0 4px; } - - // ======================== Select ========================= - // Fix overlapping between feedback icon and 's arrow. - // https://github.com/ant-design/ant-design/issues/4431 - > .@{ant-prefix}-select .@{ant-prefix}-select-arrow, - > .@{ant-prefix}-select .@{ant-prefix}-select-clear, - :not(.@{ant-prefix}-input-group-addon) > .@{ant-prefix}-select .@{ant-prefix}-select-arrow, - :not(.@{ant-prefix}-input-group-addon) > .@{ant-prefix}-select .@{ant-prefix}-select-clear, - :not(.@{ant-prefix}-input-number-group-addon) - > .@{ant-prefix}-select - .@{ant-prefix}-select-arrow, - :not(.@{ant-prefix}-input-number-group-addon) - > .@{ant-prefix}-select - .@{ant-prefix}-select-clear { - right: 32px; - } - > .@{ant-prefix}-select .@{ant-prefix}-select-selection-selected-value, - :not(.@{ant-prefix}-input-group-addon) - > .@{ant-prefix}-select - .@{ant-prefix}-select-selection-selected-value, - :not(.@{ant-prefix}-input-number-group-addon) - > .@{ant-prefix}-select - .@{ant-prefix}-select-selection-selected-value { - padding-right: 42px; - } - - // ======================= Cascader ======================== - .@{ant-prefix}-cascader-picker { - &-arrow { - margin-right: 19px; - } - - &-clear { - right: 32px; - } - } - - // ======================== Picker ========================= - // Fix issue: https://github.com/ant-design/ant-design/issues/4783 - .@{ant-prefix}-picker { - padding-right: @input-padding-horizontal-base + @font-size-base * 1.3; - - &-large { - padding-right: @input-padding-horizontal-lg + @font-size-base * 1.3; - } - - &-small { - padding-right: @input-padding-horizontal-sm + @font-size-base * 1.3; - } - } - - // ===================== Status Group ====================== - &.@{form-item-prefix-cls} { - &-has-success, - &-has-warning, - &-has-error, - &-is-validating { - // ====================== Icon ====================== - .@{form-item-prefix-cls}-children-icon { - position: absolute; - top: 50%; - right: 0; - z-index: 1; - width: @input-height-base; - height: 20px; - margin-top: -10px; - font-size: @font-size-base; - line-height: 20px; - text-align: center; - visibility: visible; - animation: zoomIn 0.3s @ease-out-back; - pointer-events: none; - } - } - } - } - - // ======================== Success ======================== - &-has-success { - &.@{form-item-prefix-cls}-has-feedback .@{form-item-prefix-cls}-children-icon { - color: @success-color; - animation-name: diffZoomIn1 !important; - } } // ======================== Warning ======================== &-has-warning { .form-control-validation(@warning-color; @warning-color; @form-warning-input-bg; @warning-color-hover; @warning-color-outline); - - &.@{form-item-prefix-cls}-has-feedback .@{form-item-prefix-cls}-children-icon { - color: @warning-color; - animation-name: diffZoomIn3 !important; - } - - // Select - .@{ant-prefix}-select:not(.@{ant-prefix}-select-disabled):not(.@{ant-prefix}-select-customize-input) { - .@{ant-prefix}-select-selector { - background-color: @form-warning-input-bg; - border-color: @warning-color !important; - } - &.@{ant-prefix}-select-open .@{ant-prefix}-select-selector, - &.@{ant-prefix}-select-focused .@{ant-prefix}-select-selector { - .active(@warning-color, @warning-color-hover, @warning-color-outline); - } - } - - // InputNumber, TimePicker - .@{ant-prefix}-input-number, - .@{ant-prefix}-picker { - background-color: @form-warning-input-bg; - border-color: @warning-color; - - &-focused, - &:focus { - .active(@warning-color, @warning-color-hover, @warning-color-outline); - } - - &:not([disabled]):hover { - background-color: @form-warning-input-bg; - border-color: @warning-color; - } - } - - .@{ant-prefix}-cascader-picker:focus .@{ant-prefix}-cascader-input { - .active(@warning-color, @warning-color-hover, @warning-color-outline); - } } // ========================= Error ========================= &-has-error { .form-control-validation(@error-color; @error-color; @form-error-input-bg; @error-color-hover; @error-color-outline); - - &.@{form-item-prefix-cls}-has-feedback .@{form-item-prefix-cls}-children-icon { - color: @error-color; - animation-name: diffZoomIn2 !important; - } - - // Select - .@{ant-prefix}-select:not(.@{ant-prefix}-select-disabled):not(.@{ant-prefix}-select-customize-input) { - .@{ant-prefix}-select-selector { - background-color: @form-error-input-bg; - border-color: @error-color !important; - } - &.@{ant-prefix}-select-open .@{ant-prefix}-select-selector, - &.@{ant-prefix}-select-focused .@{ant-prefix}-select-selector { - .active(@error-color, @error-color-hover, @error-color-outline); - } - } - - // fixes https://github.com/ant-design/ant-design/issues/20482 - .@{ant-prefix}-input-group-addon, - .@{ant-prefix}-input-number-group-addon { - .@{ant-prefix}-select { - &.@{ant-prefix}-select-single:not(.@{ant-prefix}-select-customize-input) - .@{ant-prefix}-select-selector { - background-color: inherit; - border: 0; - box-shadow: none; - } - } - } - - .@{ant-prefix}-select.@{ant-prefix}-select-auto-complete { - .@{ant-prefix}-input:focus { - border-color: @error-color; - } - } - - // InputNumber, TimePicker - .@{ant-prefix}-input-number, - .@{ant-prefix}-picker { - background-color: @form-error-input-bg; - border-color: @error-color; - - &-focused, - &:focus { - .active(@error-color, @error-color-hover, @error-color-outline); - } - - &:not([disabled]):hover { - background-color: @form-error-input-bg; - border-color: @error-color; - } - } - - .@{ant-prefix}-mention-wrapper { - .@{ant-prefix}-mention-editor { - &, - &:not([disabled]):hover { - background-color: @form-error-input-bg; - border-color: @error-color; - } - } - &.@{ant-prefix}-mention-active:not([disabled]) .@{ant-prefix}-mention-editor, - .@{ant-prefix}-mention-editor:not([disabled]):focus { - .active(@error-color, @error-color-hover, @error-color-outline); - } - } - - // Cascader - .@{ant-prefix}-cascader-picker { - &:hover - .@{ant-prefix}-cascader-picker-label:hover - + .@{ant-prefix}-cascader-input.@{ant-prefix}-input { - border-color: @error-color; - } - - &:focus .@{ant-prefix}-cascader-input { - background-color: @form-error-input-bg; - .active(@error-color, @error-color-hover, @error-color-outline); - } - } - - // Transfer - .@{ant-prefix}-transfer { - &-list { - border-color: @error-color; - - &-search:not([disabled]) { - border-color: @input-border-color; - - &:hover { - .hover(); - } - - &:focus { - .active(); - } - } - } - } - - // Radio.Group - .@{ant-prefix}-radio-button-wrapper { - border-color: @error-color !important; - - &:not(:first-child) { - &::before { - background-color: @error-color; - } - } - } - - // Mentions - .@{ant-prefix}-mentions { - border-color: @error-color !important; - - &-focused, - &:focus { - .active(@error-color, @error-color-hover, @error-color-outline); - } - } - } - - // ====================== Validating ======================= - &-is-validating { - &.@{form-item-prefix-cls}-has-feedback .@{form-item-prefix-cls}-children-icon { - display: inline-block; - color: @primary-color; - } } } diff --git a/components/input/ClearableLabeledInput.tsx b/components/input/ClearableLabeledInput.tsx index 54272c724d..51b23939c5 100644 --- a/components/input/ClearableLabeledInput.tsx +++ b/components/input/ClearableLabeledInput.tsx @@ -1,11 +1,13 @@ -import * as React from 'react'; -import classNames from 'classnames'; import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; -import { tuple } from '../_util/type'; -import type { InputProps } from './Input'; +import classNames from 'classnames'; +import * as React from 'react'; import { DirectionType } from '../config-provider'; import { SizeType } from '../config-provider/SizeContext'; +import { FormItemStatusContext, FormItemStatusContextProps } from '../form/context'; import { cloneElement } from '../_util/reactNode'; +import { getStatusClassNames, InputStatus } from '../_util/statusUtils'; +import { tuple } from '../_util/type'; +import type { InputProps } from './Input'; import { getInputClassName, hasPrefixSuffix } from './utils'; const ClearableInputType = tuple('text', 'input'); @@ -40,6 +42,7 @@ export interface ClearableInputProps extends BasicProps { addonBefore?: React.ReactNode; addonAfter?: React.ReactNode; triggerFocus?: () => void; + status?: InputStatus; } class ClearableLabeledInput extends React.Component { @@ -91,7 +94,11 @@ class ClearableLabeledInput extends React.Component { return null; } - renderLabeledIcon(prefixCls: string, element: React.ReactElement) { + renderLabeledIcon( + prefixCls: string, + element: React.ReactElement, + statusContext: FormItemStatusContextProps, + ) { const { focused, value, @@ -106,7 +113,11 @@ class ClearableLabeledInput extends React.Component { readOnly, bordered, hidden, + status: customStatus, } = this.props; + + const { status: contextStatus, hasFeedback } = statusContext; + if (!hasPrefixSuffix(this.props)) { return cloneElement(element, { value, @@ -116,18 +127,22 @@ class ClearableLabeledInput extends React.Component { const suffixNode = this.renderSuffix(prefixCls); const prefixNode = prefix ? {prefix} : null; - const affixWrapperCls = classNames(`${prefixCls}-affix-wrapper`, { - [`${prefixCls}-affix-wrapper-focused`]: focused, - [`${prefixCls}-affix-wrapper-disabled`]: disabled, - [`${prefixCls}-affix-wrapper-sm`]: size === 'small', - [`${prefixCls}-affix-wrapper-lg`]: size === 'large', - [`${prefixCls}-affix-wrapper-input-with-clear-btn`]: suffix && allowClear && value, - [`${prefixCls}-affix-wrapper-rtl`]: direction === 'rtl', - [`${prefixCls}-affix-wrapper-readonly`]: readOnly, - [`${prefixCls}-affix-wrapper-borderless`]: !bordered, - // className will go to addon wrapper - [`${className}`]: !hasAddon(this.props) && className, - }); + const affixWrapperCls = classNames( + `${prefixCls}-affix-wrapper`, + { + [`${prefixCls}-affix-wrapper-focused`]: focused, + [`${prefixCls}-affix-wrapper-disabled`]: disabled, + [`${prefixCls}-affix-wrapper-sm`]: size === 'small', + [`${prefixCls}-affix-wrapper-lg`]: size === 'large', + [`${prefixCls}-affix-wrapper-input-with-clear-btn`]: suffix && allowClear && value, + [`${prefixCls}-affix-wrapper-rtl`]: direction === 'rtl', + [`${prefixCls}-affix-wrapper-readonly`]: readOnly, + [`${prefixCls}-affix-wrapper-borderless`]: !bordered, + // className will go to addon wrapper + [`${className}`]: !hasAddon(this.props) && className, + }, + getStatusClassNames(`${prefixCls}-affix-wrapper`, contextStatus || customStatus, hasFeedback), + ); return ( { ); } - renderInputWithLabel(prefixCls: string, labeledElement: React.ReactElement) { - const { addonBefore, addonAfter, style, size, className, direction, hidden } = this.props; + renderInputWithLabel( + prefixCls: string, + labeledElement: React.ReactElement, + statusContext: FormItemStatusContextProps, + ) { + const { + addonBefore, + addonAfter, + style, + size, + className, + direction, + hidden, + status: customStatus, + } = this.props; + const { status: contextStatus, hasFeedback } = statusContext; // Not wrap when there is not addons if (!hasAddon(this.props)) { return labeledElement; @@ -172,6 +201,7 @@ class ClearableLabeledInput extends React.Component { [`${prefixCls}-group-wrapper-lg`]: size === 'large', [`${prefixCls}-group-wrapper-rtl`]: direction === 'rtl', }, + getStatusClassNames(`${prefixCls}-group-wrapper`, contextStatus || customStatus, hasFeedback), className, ); @@ -188,8 +218,24 @@ class ClearableLabeledInput extends React.Component { ); } - renderTextAreaWithClearIcon(prefixCls: string, element: React.ReactElement) { - const { value, allowClear, className, style, direction, bordered, hidden } = this.props; + renderTextAreaWithClearIcon( + prefixCls: string, + element: React.ReactElement, + statusContext: FormItemStatusContextProps, + ) { + const { + value, + allowClear, + className, + style, + direction, + bordered, + hidden, + status: customStatus, + } = this.props; + + const { status: contextStatus, hasFeedback } = statusContext; + if (!allowClear) { return cloneElement(element, { value, @@ -198,6 +244,7 @@ class ClearableLabeledInput extends React.Component { const affixWrapperCls = classNames( `${prefixCls}-affix-wrapper`, `${prefixCls}-affix-wrapper-textarea-with-clear-btn`, + getStatusClassNames(`${prefixCls}-affix-wrapper`, contextStatus || customStatus, hasFeedback), { [`${prefixCls}-affix-wrapper-rtl`]: direction === 'rtl', [`${prefixCls}-affix-wrapper-borderless`]: !bordered, @@ -217,11 +264,21 @@ class ClearableLabeledInput extends React.Component { } render() { - const { prefixCls, inputType, element } = this.props; - if (inputType === ClearableInputType[0]) { - return this.renderTextAreaWithClearIcon(prefixCls, element); - } - return this.renderInputWithLabel(prefixCls, this.renderLabeledIcon(prefixCls, element)); + return ( + + {statusContext => { + const { prefixCls, inputType, element } = this.props; + if (inputType === ClearableInputType[0]) { + return this.renderTextAreaWithClearIcon(prefixCls, element, statusContext); + } + return this.renderInputWithLabel( + prefixCls, + this.renderLabeledIcon(prefixCls, element, statusContext), + statusContext, + ); + }} + + ); } } diff --git a/components/input/Input.tsx b/components/input/Input.tsx index 02d0b192cd..96d5a5f3df 100644 --- a/components/input/Input.tsx +++ b/components/input/Input.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import classNames from 'classnames'; import omit from 'rc-util/lib/omit'; +import { ValidateStatus } from '../form/FormItem'; import type Group from './Group'; import type Search from './Search'; import type TextArea from './TextArea'; @@ -11,6 +12,8 @@ import { ConfigConsumer, ConfigConsumerProps, DirectionType } from '../config-pr import SizeContext, { SizeType } from '../config-provider/SizeContext'; import devWarning from '../_util/devWarning'; import { getInputClassName, hasPrefixSuffix } from './utils'; +import { FormItemStatusContext } from '../form/context'; +import { getFeedbackIcon, InputStatus } from '../_util/statusUtils'; export interface InputFocusOptions extends FocusOptions { cursor?: 'start' | 'end' | 'all'; @@ -59,6 +62,7 @@ export interface InputProps showCount?: boolean | ShowCountProps; bordered?: boolean; htmlSize?: number; + status?: InputStatus; } export function fixControlledValue(value: T) { @@ -280,6 +284,7 @@ class Input extends React.Component { prefixCls: string, size: SizeType | undefined, bordered: boolean, + status?: ValidateStatus, input: ConfigConsumerProps['input'] = {}, ) => { const { @@ -307,7 +312,9 @@ class Input extends React.Component { 'bordered', 'htmlSize', 'showCount', + 'status', ]); + return ( { onBlur={this.onBlur} onKeyDown={this.handleKeyDown} className={classNames( - getInputClassName(prefixCls, bordered, customizeSize || size, disabled, this.direction), + getInputClassName( + prefixCls, + bordered, + customizeSize || size, + disabled, + this.direction, + status, + ), { [className!]: className && !addonBefore && !addonAfter, }, @@ -369,49 +383,66 @@ class Input extends React.Component { dataCount = `${valueLength}${hasMaxLength ? ` / ${maxLength}` : ''}`; } return ( - <> - {!!showCount && ( - - {dataCount} - - )} - {suffix} - > + !!showCount && ( + + {dataCount} + + ) ); } return null; }; + renderSuffix = (prefixCls: string, hasFeedback?: boolean, status?: ValidateStatus) => { + const { suffix, showCount } = this.props; + + return ( + (showCount || suffix || hasFeedback) && ( + <> + {this.renderShowCountSuffix(prefixCls)} + {suffix} + {hasFeedback && getFeedbackIcon(prefixCls, status)} + > + ) + ); + }; + renderComponent = ({ getPrefixCls, direction, input }: ConfigConsumerProps) => { const { value, focused } = this.state; - const { prefixCls: customizePrefixCls, bordered = true } = this.props; + const { prefixCls: customizePrefixCls, bordered = true, status: customStatus } = this.props; const prefixCls = getPrefixCls('input', customizePrefixCls); this.direction = direction; - const showCountSuffix = this.renderShowCountSuffix(prefixCls); - return ( {size => ( - + + {({ status: contextStatus, hasFeedback }) => { + const mergedStatus = contextStatus || customStatus; + + return ( + + ); + }} + )} ); diff --git a/components/input/TextArea.tsx b/components/input/TextArea.tsx index 0565dd6010..aa8d46c1ca 100644 --- a/components/input/TextArea.tsx +++ b/components/input/TextArea.tsx @@ -1,13 +1,15 @@ -import * as React from 'react'; +import classNames from 'classnames'; import RcTextArea, { TextAreaProps as RcTextAreaProps } from 'rc-textarea'; import ResizableTextArea from 'rc-textarea/lib/ResizableTextArea'; -import omit from 'rc-util/lib/omit'; -import classNames from 'classnames'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; -import ClearableLabeledInput from './ClearableLabeledInput'; +import omit from 'rc-util/lib/omit'; +import * as React from 'react'; import { ConfigContext } from '../config-provider'; -import { fixControlledValue, resolveOnChange, triggerFocus, InputFocusOptions } from './Input'; import SizeContext, { SizeType } from '../config-provider/SizeContext'; +import { FormItemStatusContext } from '../form/context'; +import { getFeedbackIcon, getStatusClassNames, InputStatus } from '../_util/statusUtils'; +import ClearableLabeledInput from './ClearableLabeledInput'; +import { fixControlledValue, InputFocusOptions, resolveOnChange, triggerFocus } from './Input'; interface ShowCountProps { formatter: (args: { count: number; maxLength?: number }) => string; @@ -22,6 +24,7 @@ export interface TextAreaProps extends RcTextAreaProps { bordered?: boolean; showCount?: boolean | ShowCountProps; size?: SizeType; + status?: InputStatus; } export interface TextAreaRef { @@ -43,6 +46,7 @@ const TextArea = React.forwardRef( onCompositionStart, onCompositionEnd, onChange, + status: customStatus, ...props }, ref, @@ -50,6 +54,9 @@ const TextArea = React.forwardRef( const { getPrefixCls, direction } = React.useContext(ConfigContext); const size = React.useContext(SizeContext); + const { status: contextStatus, hasFeedback } = React.useContext(FormItemStatusContext); + const mergedStatus = contextStatus || customStatus; + const innerRef = React.useRef(null); const clearableInputRef = React.useRef(null); @@ -124,12 +131,15 @@ const TextArea = React.forwardRef( const textArea = ( ( handleReset={handleReset} ref={clearableInputRef} bordered={bordered} + status={customStatus} style={showCount ? undefined : style} /> ); // Only show text area wrapper when needed - if (showCount) { + if (showCount || hasFeedback) { const valueLength = [...val].length; let dataCount = ''; @@ -180,14 +191,16 @@ const TextArea = React.forwardRef( `${prefixCls}-textarea`, { [`${prefixCls}-textarea-rtl`]: direction === 'rtl', + [`${prefixCls}-textarea-show-count`]: showCount, }, - `${prefixCls}-textarea-show-count`, + getStatusClassNames(`${prefixCls}-textarea`, mergedStatus, hasFeedback), className, )} style={style} data-count={dataCount} > {textareaNode} + {hasFeedback && getFeedbackIcon(prefixCls, mergedStatus)} ); } diff --git a/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap index f7d7cc1217..294282f6dc 100644 --- a/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -9529,6 +9529,117 @@ Array [ ] `; +exports[`renders ./components/input/demo/status.md extend context correctly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + exports[`renders ./components/input/demo/textarea.md extend context correctly 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + exports[`renders ./components/input/demo/textarea.md correctly 1`] = ` ( + + + + } placeholder="Error with prefix" /> + } placeholder="Warning with prefix" /> + +); + +ReactDOM.render(, mountNode); +``` diff --git a/components/input/index.en-US.md b/components/input/index.en-US.md index 2836265010..487e3b0515 100644 --- a/components/input/index.en-US.md +++ b/components/input/index.en-US.md @@ -27,6 +27,7 @@ A basic widget for getting the user input is a text field. Keyboard and mouse ca | id | The ID for input | string | - | | | maxLength | The max length | number | - | | | showCount | Whether show text count | boolean \| { formatter: ({ count: number, maxLength?: number }) => ReactNode } | false | 4.18.0 | +| status | Set validation status | 'error' \| 'warning' | - | 4.19.0 | | prefix | The prefix icon for the Input | ReactNode | - | | | size | The size of the input box. Note: in the context of a form, the `large` size is used | `large` \| `middle` \| `small` | - | | | suffix | The suffix icon for the Input | ReactNode | - | | diff --git a/components/input/index.zh-CN.md b/components/input/index.zh-CN.md index 96a7b434c8..e5a0b783ac 100644 --- a/components/input/index.zh-CN.md +++ b/components/input/index.zh-CN.md @@ -28,6 +28,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/xS9YEJhfe/Input.svg | id | 输入框的 id | string | - | | | maxLength | 最大长度 | number | - | | | showCount | 是否展示字数 | boolean \| { formatter: ({ count: number, maxLength?: number }) => ReactNode } | false | 4.18.0 | +| status | 设置校验状态 | 'error' \| 'warning' | - | 4.19.0 | | prefix | 带有前缀图标的 input | ReactNode | - | | | size | 控件大小。注:标准表单内的输入框大小限制为 `large` | `large` \| `middle` \| `small` | - | | | suffix | 带有后缀图标的 input | ReactNode | - | | diff --git a/components/input/style/affix.less b/components/input/style/affix.less index 3cffa963b6..5f92404441 100644 --- a/components/input/style/affix.less +++ b/components/input/style/affix.less @@ -50,6 +50,10 @@ display: flex; flex: none; align-items: center; + + > *:not(:last-child) { + margin-right: 8px; + } } &-show-count-suffix { diff --git a/components/input/style/index.less b/components/input/style/index.less index 79c8c00980..d01262ccb3 100644 --- a/components/input/style/index.less +++ b/components/input/style/index.less @@ -3,6 +3,7 @@ @import './mixin'; @import './affix'; @import './allow-clear'; +@import './status'; @input-prefix-cls: ~'@{ant-prefix}-input'; diff --git a/components/input/style/index.tsx b/components/input/style/index.tsx index 416ec0177e..40150302b3 100644 --- a/components/input/style/index.tsx +++ b/components/input/style/index.tsx @@ -1,5 +1,6 @@ import '../../style/index.less'; import './index.less'; +// deps-lint-skip: form // style dependencies import '../../button/style'; diff --git a/components/input/style/status.less b/components/input/style/status.less new file mode 100644 index 0000000000..cfeb21e411 --- /dev/null +++ b/components/input/style/status.less @@ -0,0 +1,132 @@ +@import './mixin'; + +@input-prefix-cls: ~'@{ant-prefix}-input'; + +.status-color( + @prefix-cls: @input-prefix-cls; + @text-color: @input-color; + @border-color: @input-border-color; + @background-color: @input-bg; + @hoverBorderColor: @primary-color-hover; + @outlineColor: @primary-color-outline; +) { + &:not(.@{prefix-cls}-disabled):not(.@{prefix-cls}-borderless).@{prefix-cls} { + &, + &:hover { + background: @background-color; + border-color: @border-color; + } + + &:focus, + &-focused { + .active(@text-color, @hoverBorderColor, @outlineColor); + } + } +} + +.status-color-common( + @prefix-cls: @input-prefix-cls; + @text-color: @input-color; + @border-color: @input-border-color; + @background-color: @input-bg; + @hoverBorderColor: @primary-color-hover; + @outlineColor: @primary-color-outline; +) { + .@{prefix-cls}-feedback-icon { + color: @text-color; + } + + .@{prefix-cls}-prefix { + color: @text-color; + } +} + +.group-status-color( + @prefix-cls: @input-prefix-cls; + @text-color: @input-color; + @border-color: @input-border-color; +) { + .@{prefix-cls}-group-addon { + color: @text-color; + border-color: @border-color; + } +} + +@input-wrapper-cls: @input-prefix-cls, ~'@{input-prefix-cls}-affix-wrapper'; + +each(@input-wrapper-cls, { + .@{value} { + &-status-error { + .status-color(@value, @error-color, @error-color, @input-bg, @error-color-hover, @error-color-outline); + .status-color-common(@input-prefix-cls, @error-color, @error-color, @input-bg, @error-color-hover, @error-color-outline); + } + + &-status-warning { + .status-color(@value, @warning-color, @warning-color, @input-bg, @warning-color-hover, @warning-color-outline); + .status-color-common(@input-prefix-cls, @warning-color, @warning-color, @input-bg, @warning-color-hover, @warning-color-outline); + } + } +}); + +.@{input-prefix-cls}-textarea, +.@{input-prefix-cls}-affix-wrapper { + &-status-validating { + .@{input-prefix-cls}-feedback-icon { + display: inline-block; + color: @primary-color; + } + } + + &-status-success { + .@{input-prefix-cls}-feedback-icon { + color: @success-color; + animation-name: diffZoomIn1 !important; + } + } +} + +.@{input-prefix-cls}-textarea { + &-status-error { + .@{input-prefix-cls}-feedback-icon { + color: @error-color; + } + } + + &-status-warning { + .@{input-prefix-cls}-feedback-icon { + color: @warning-color; + } + } + + .@{input-prefix-cls}-feedback-icon { + position: absolute; + top: 0; + right: @input-padding-horizontal-base; + bottom: 0; + z-index: 1; + display: inline-flex; + align-items: center; + margin: auto; + } + + &-status-error, + &-status-warning, + &-status-success, + &-status-validating { + &.@{input-prefix-cls}-textarea-has-feedback { + .@{input-prefix-cls} { + padding-right: 24px; + } + } + } +} + +.@{input-prefix-cls}-group-wrapper { + &-status-error { + .group-status-color(@input-prefix-cls, @error-color, @error-color); + } + + &-status-warning { + .group-status-color(@input-prefix-cls, @warning-color, @warning-color); + } +} diff --git a/components/input/utils.ts b/components/input/utils.ts index dbc492c270..39eb862fda 100644 --- a/components/input/utils.ts +++ b/components/input/utils.ts @@ -1,8 +1,10 @@ import classNames from 'classnames'; +import { ValidateStatus } from '../form/FormItem'; import type { DirectionType } from '../config-provider'; import type { SizeType } from '../config-provider/SizeContext'; import type { ClearableInputProps } from './ClearableLabeledInput'; import type { InputProps } from './Input'; +import { getStatusClassNames } from '../_util/statusUtils'; export function getInputClassName( prefixCls: string, @@ -10,14 +12,20 @@ export function getInputClassName( size?: SizeType, disabled?: boolean, direction?: DirectionType, + status?: ValidateStatus, + hasFeedback?: boolean, ) { - return classNames(prefixCls, { - [`${prefixCls}-sm`]: size === 'small', - [`${prefixCls}-lg`]: size === 'large', - [`${prefixCls}-disabled`]: disabled, - [`${prefixCls}-rtl`]: direction === 'rtl', - [`${prefixCls}-borderless`]: !bordered, - }); + return classNames( + prefixCls, + { + [`${prefixCls}-sm`]: size === 'small', + [`${prefixCls}-lg`]: size === 'large', + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-rtl`]: direction === 'rtl', + [`${prefixCls}-borderless`]: !bordered, + }, + getStatusClassNames(prefixCls, status, hasFeedback), + ); } export function hasPrefixSuffix(props: InputProps | ClearableInputProps) {