diff --git a/components/cascader/__tests__/__snapshots__/demo.test.js.snap b/components/cascader/__tests__/__snapshots__/demo.test.js.snap index c1ce961fe7..185cec5e8e 100644 --- a/components/cascader/__tests__/__snapshots__/demo.test.js.snap +++ b/components/cascader/__tests__/__snapshots__/demo.test.js.snap @@ -80,6 +80,46 @@ exports[`renders ./components/cascader/demo/change-on-select.md correctly 1`] = `; +exports[`renders ./components/cascader/demo/custom-dropdown.md correctly 1`] = ` + + + + + + + +`; + exports[`renders ./components/cascader/demo/custom-render.md correctly 1`] = ` +Array [ - + , +
, +
, ab - - + , +
, +
, + + + + + + + , +
, +
, + + + + + + + , +] `; diff --git a/components/cascader/demo/custom-dropdown.md b/components/cascader/demo/custom-dropdown.md new file mode 100644 index 0000000000..70c9e6b0aa --- /dev/null +++ b/components/cascader/demo/custom-dropdown.md @@ -0,0 +1,72 @@ +--- +order: 12 +title: + zh-CN: 扩展菜单 + en-US: Custom dropdown +--- + +## zh-CN + +使用 `dropdownRender` 对下拉菜单进行自由扩展。 + +## en-US + +Customize the dropdown menu via `dropdownRender`. + +```jsx +import { Cascader, Divider } from 'antd'; + +const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [ + { + value: 'hangzhou', + label: 'Hangzhou', + children: [ + { + value: 'xihu', + label: 'West Lake', + }, + ], + }, + ], + }, + { + value: 'jiangsu', + label: 'Jiangsu', + children: [ + { + value: 'nanjing', + label: 'Nanjing', + children: [ + { + value: 'zhonghuamen', + label: 'Zhong Hua Men', + }, + ], + }, + ], + }, +]; + +function dropdownRender(menus) { + return ( +
+ {menus} + +
The footer is not very short.
+
+ ); +} + +ReactDOM.render( + , + mountNode, +); +``` diff --git a/components/cascader/demo/suffix.md b/components/cascader/demo/suffix.md index ebbb61b17e..e426bc9f3f 100644 --- a/components/cascader/demo/suffix.md +++ b/components/cascader/demo/suffix.md @@ -2,17 +2,17 @@ order: 11 debug: true title: - zh-CN: 后缀图标 - en-US: Suffix + zh-CN: 自定义图标 + en-US: Custom Icons --- ## zh-CN -省市区级联。 +通过 `suffixIcon` 自定义选择框后缀图标,通过 `expandIcon` 自定义次级菜单展开图标。 ## en-US -Cascade selection box for selecting province/city/district. +Use `suffixIcon` to customize the selection box suffix icon, and use `expandIcon` to customize the current item expand icon. ```jsx import { Cascader } from 'antd'; @@ -58,21 +58,28 @@ function onChange(value) { } ReactDOM.render( -
+ <> } options={options} onChange={onChange} placeholder="Please select" /> +
+
+ +
+
} options={options} onChange={onChange} placeholder="Please select" /> -
, +
+
+ + , mountNode, ); ``` diff --git a/components/cascader/index.en-US.md b/components/cascader/index.en-US.md index 5780557dc7..a22e9cfd75 100644 --- a/components/cascader/index.en-US.md +++ b/components/cascader/index.en-US.md @@ -30,6 +30,7 @@ Cascade selection box. | disabled | whether disabled select | boolean | false | | | displayRender | render function of displaying selected options | `(label, selectedOptions) => ReactNode` | `label => label.join(' / ')` | | | expandTrigger | expand current item when click or hover, one of 'click' 'hover' | string | `click` | | +| expandIcon | customize the current item expand icon | ReactNode | - | 4.4.0 | | fieldNames | custom field name for label and value and children | object | `{ label: 'label', value: 'value', children: 'children' }` | | | getPopupContainer | Parent Node which the selector should be rendered to. Default to `body`. When position issues happen, try to modify it into scrollable content and position it relative.[example](https://codepen.io/afc163/pen/zEjNOy?editors=0010) | Function(triggerNode) | () => document.body | | | loadData | To load option lazily, and it cannot work with `showSearch` | `(selectedOptions) => void` | - | | @@ -44,6 +45,7 @@ Cascade selection box. | style | additional style | CSSProperties | - | | | suffixIcon | The custom suffix icon | ReactNode | - | | | value | selected value | string\[] \| number\[] | - | | +| dropdownRender | Customize dropdown content | `(menus: ReactNode) => ReactNode` | - | 4.4.0 | | onChange | callback when finishing cascader select | `(value, selectedOptions) => void` | - | | | onPopupVisibleChange | callback when popup shown or hidden | `(value) => void` | - | | diff --git a/components/cascader/index.tsx b/components/cascader/index.tsx index ede83d54ff..59ea5ae5cb 100644 --- a/components/cascader/index.tsx +++ b/components/cascader/index.tsx @@ -97,6 +97,7 @@ export interface CascaderProps { loadData?: (selectedOptions?: CascaderOptionType[]) => void; /** 次级菜单的展开方式,可选 'click' 和 'hover' */ expandTrigger?: CascaderExpandTrigger; + expandIcon?: React.ReactNode; /** 当此项为 true 时,点选每级菜单选项值都会发生变化 */ changeOnSelect?: boolean; /** 浮层可见变化时回调 */ @@ -108,6 +109,7 @@ export interface CascaderProps { /** use this after antd@3.7.0 */ fieldNames?: FieldNamesType; suffixIcon?: React.ReactNode; + dropdownRender?: (menus: React.ReactNode) => React.ReactNode } export interface CascaderState { @@ -458,11 +460,14 @@ class Cascader extends React.Component { allowClear, showSearch = false, suffixIcon, + expandIcon, notFoundContent, popupClassName, bordered, + dropdownRender, ...otherProps } = props; + const mergedSize = customizeSize || size; const { value, inputFocused } = state; @@ -592,9 +597,11 @@ class Cascader extends React.Component {
); - let expandIcon = ; - if (isRtlLayout) { - expandIcon = ; + let expandIconNode; + if (expandIcon) { + expandIconNode = expandIcon; + } else { + expandIconNode = isRtlLayout ? : ; } const loadingIcon = ( @@ -621,10 +628,11 @@ class Cascader extends React.Component { onPopupVisibleChange={this.handlePopupVisibleChange} onChange={this.handleChange} dropdownMenuColumnStyle={dropdownMenuColumnStyle} - expandIcon={expandIcon} + expandIcon={expandIconNode} loadingIcon={loadingIcon} popupClassName={rcCascaderPopupClassName} popupPlacement={this.getPopupPlacement(direction)} + dropdownRender={dropdownRender} > {input} diff --git a/components/cascader/index.zh-CN.md b/components/cascader/index.zh-CN.md index cf0bf66fb1..51e3d10833 100644 --- a/components/cascader/index.zh-CN.md +++ b/components/cascader/index.zh-CN.md @@ -31,6 +31,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/UdS8y8xyZ/Cascader.svg | disabled | 禁用 | boolean | false | | | displayRender | 选择后展示的渲染函数 | `(label, selectedOptions) => ReactNode` | `label => label.join(' / ')` | | | expandTrigger | 次级菜单的展开方式,可选 'click' 和 'hover' | string | `click` | | +| expandIcon | 自定义次级菜单展开图标 | ReactNode | - | 4.4.0 | | fieldNames | 自定义 options 中 label name children 的字段 | object | `{ label: 'label', value: 'value', children: 'children' }` | | | getPopupContainer | 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。[示例](https://codepen.io/afc163/pen/zEjNOy?editors=0010) | Function(triggerNode) | () => document.body | | | loadData | 用于动态加载选项,无法与 `showSearch` 一起使用 | `(selectedOptions) => void` | - | | @@ -45,6 +46,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/UdS8y8xyZ/Cascader.svg | style | 自定义样式 | CSSProperties | - | | | suffixIcon | 自定义的选择框后缀图标 | ReactNode | - | | | value | 指定选中项 | string\[] \| number\[] | - | | +| dropdownRender | 自定义下拉框内容 | `(menus: ReactNode) => ReactNode` | - | 4.4.0 | | onChange | 选择完成后的回调 | `(value, selectedOptions) => void` | - | | | onPopupVisibleChange | 显示/隐藏浮层的回调 | `(value) => void` | - | | diff --git a/components/collapse/Collapse.tsx b/components/collapse/Collapse.tsx index 3c08ae111b..85c2e4ce3e 100644 --- a/components/collapse/Collapse.tsx +++ b/components/collapse/Collapse.tsx @@ -23,6 +23,7 @@ export interface CollapseProps { prefixCls?: string; expandIcon?: (panelProps: PanelProps) => React.ReactNode; expandIconPosition?: ExpandIconPosition; + ghost?: boolean; } interface PanelProps { @@ -42,7 +43,7 @@ interface CollapseInterface extends React.FC { const Collapse: CollapseInterface = props => { const { getPrefixCls, direction } = React.useContext(ConfigContext); - const { prefixCls: customizePrefixCls, className = '', bordered } = props; + const { prefixCls: customizePrefixCls, className = '', bordered, ghost } = props; const prefixCls = getPrefixCls('collapse', customizePrefixCls); const getIconPosition = () => { @@ -72,6 +73,7 @@ const Collapse: CollapseInterface = props => { [`${prefixCls}-borderless`]: !bordered, [`${prefixCls}-icon-position-${iconPosition}`]: true, [`${prefixCls}-rtl`]: direction === 'rtl', + [`${prefixCls}-ghost`]: !!ghost, }, className, ); diff --git a/components/collapse/__tests__/__snapshots__/demo.test.js.snap b/components/collapse/__tests__/__snapshots__/demo.test.js.snap index 55d8483d31..eca6fbf59d 100644 --- a/components/collapse/__tests__/__snapshots__/demo.test.js.snap +++ b/components/collapse/__tests__/__snapshots__/demo.test.js.snap @@ -718,6 +718,125 @@ exports[`renders ./components/collapse/demo/extra.md correctly 1`] = ` `; +exports[`renders ./components/collapse/demo/ghost.md correctly 1`] = ` +
+
+
+ + + + This is panel header 1 +
+
+
+

+ + A dog is a type of domesticated animal. + Known for its loyalty and faithfulness, + it can be found as a welcome guest in many households across the world. + +

+
+
+
+
+ +
+
+ +
+
+`; + exports[`renders ./components/collapse/demo/mix.md correctly 1`] = `
+ +

{text}

+
+ +

{text}

+
+ +

{text}

+
+ , + mountNode, +); +``` diff --git a/components/collapse/index.en-US.md b/components/collapse/index.en-US.md index e8bbbcbfd2..b107018b2e 100644 --- a/components/collapse/index.en-US.md +++ b/components/collapse/index.en-US.md @@ -27,6 +27,7 @@ A content area which can be collapsed and expanded. | expandIcon | allow to customize collapse icon | (panelProps) => ReactNode | - | | | expandIconPosition | Set expand icon position | `left` \| `right` | - | | | destroyInactivePanel | Destroy Inactive Panel | boolean | false | | +| ghost | make the collapse borderless and its background transparent | boolean | false | 4.4.0 | ### Collapse.Panel diff --git a/components/collapse/index.zh-CN.md b/components/collapse/index.zh-CN.md index 925cfb40ac..65f9f20253 100644 --- a/components/collapse/index.zh-CN.md +++ b/components/collapse/index.zh-CN.md @@ -28,6 +28,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/IxH16B9RD/Collapse.svg | expandIcon | 自定义切换图标 | (panelProps) => ReactNode | - | | | expandIconPosition | 设置图标位置 | `left` \| `right` | - | | | destroyInactivePanel | 销毁折叠隐藏的面板 | boolean | false | | +| ghost | 使折叠面板透明且无边框 | boolean | false | 4.4.0 | ### Collapse.Panel diff --git a/components/collapse/style/index.less b/components/collapse/style/index.less index 7be3c23aee..e854a9aed1 100644 --- a/components/collapse/style/index.less +++ b/components/collapse/style/index.less @@ -125,6 +125,22 @@ padding-top: 4px; } + &-ghost { + background-color: transparent; + border: 0; + > .@{collapse-prefix-cls}-item { + border-bottom: 0; + > .@{collapse-prefix-cls}-content { + background-color: transparent; + border-top: 0; + > .@{collapse-prefix-cls}-content-box { + padding-top: 12px; + padding-bottom: 12px; + } + } + } + } + & &-item-disabled > &-header { &, & > .arrow { diff --git a/components/config-provider/__tests__/__snapshots__/components.test.js.snap b/components/config-provider/__tests__/__snapshots__/components.test.js.snap index 43935822d6..3a40254eb8 100644 --- a/components/config-provider/__tests__/__snapshots__/components.test.js.snap +++ b/components/config-provider/__tests__/__snapshots__/components.test.js.snap @@ -16901,9 +16901,11 @@ exports[`ConfigProvider components Pagination configProvider 1`] = ` class="config-pagination-prev config-pagination-disabled" title="Previous Page" > - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - - +
  • - « +
    - » +
    { ); expect(wrapper2.find('button.forceRender').length).toBe(1); }); + + it('support closeIcon', () => { + const wrapper = render( + close} width={400} getContainer={false}> + Here is content of Drawer + , + ); + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/components/drawer/__tests__/DrawerEvent.test.js b/components/drawer/__tests__/DrawerEvent.test.js index d7aca15aab..07d94df43f 100644 --- a/components/drawer/__tests__/DrawerEvent.test.js +++ b/components/drawer/__tests__/DrawerEvent.test.js @@ -82,7 +82,7 @@ describe('Drawer', () => { expect(wrapper.instance().state.visible).toBe(true); }); - it('destroyOnClose is true onClose', () => { + it('dom should be removed after close when destroyOnClose is true', () => { const wrapper = mount(); wrapper.find('button.ant-btn').simulate('click'); expect(wrapper.find('.ant-drawer-wrapper-body').exists()).toBe(true); @@ -94,6 +94,18 @@ describe('Drawer', () => { expect(wrapper.find('.ant-drawer-wrapper-body').exists()).toBe(false); }); + it('dom should be existed after close when destroyOnClose is false', () => { + const wrapper = mount(); + wrapper.find('button.ant-btn').simulate('click'); + expect(wrapper.find('.ant-drawer-wrapper-body').exists()).toBe(true); + + wrapper.setState({ + visible: false, + }); + wrapper.find('.ant-drawer-wrapper-body').simulate('transitionend'); + expect(wrapper.find('.ant-drawer-wrapper-body').exists()).toBe(true); + }); + it('no mask and no closable', () => { const wrapper = mount(); diff --git a/components/drawer/__tests__/__snapshots__/Drawer.test.js.snap b/components/drawer/__tests__/__snapshots__/Drawer.test.js.snap index 515c8f6659..5501c64ae1 100644 --- a/components/drawer/__tests__/__snapshots__/Drawer.test.js.snap +++ b/components/drawer/__tests__/__snapshots__/Drawer.test.js.snap @@ -558,3 +558,49 @@ exports[`Drawer style/drawerStyle/headerStyle/bodyStyle should work 1`] = `
  • `; + +exports[`Drawer support closeIcon 1`] = ` +
    +
    +
    +
    +
    +
    +
    + +
    +
    + Here is content of Drawer +
    +
    +
    +
    +
    +
    +`; diff --git a/components/drawer/index.en-US.md b/components/drawer/index.en-US.md index ef2e2600d7..25e6da6157 100644 --- a/components/drawer/index.en-US.md +++ b/components/drawer/index.en-US.md @@ -21,6 +21,7 @@ A Drawer is a panel that is typically overlaid on top of a page and slides in fr | Props | Description | Type | Default | | --- | --- | --- | --- | | closable | Whether a close (x) button is visible on top right of the Drawer dialog or not. | boolean | true | +| closeIcon | custom close icon | ReactNode | `` | | destroyOnClose | Whether to unmount child components on closing drawer or not. | boolean | false | | forceRender | Prerender Drawer component forcely | boolean | false | | getContainer | Return the mounted node for Drawer. | HTMLElement \| `() => HTMLElement` \| Selectors \| false | 'body' | diff --git a/components/drawer/index.tsx b/components/drawer/index.tsx index 309f5544eb..8f12376d9e 100644 --- a/components/drawer/index.tsx +++ b/components/drawer/index.tsx @@ -21,6 +21,7 @@ const PlacementTypes = tuple('top', 'right', 'bottom', 'left'); type placementType = typeof PlacementTypes[number]; export interface DrawerProps { closable?: boolean; + closeIcon?: React.ReactNode; destroyOnClose?: boolean; forceRender?: boolean; getContainer?: string | HTMLElement | getContainerFunc | false; @@ -195,7 +196,7 @@ class Drawer extends React.Component, prefixCls, onClose } = this.props; return ( closable && ( // eslint-disable-next-line react/button-has-type @@ -209,7 +210,7 @@ class Drawer extends React.Component - + {closeIcon} ) ); @@ -283,6 +284,7 @@ class Drawer extends React.Component` | | destroyOnClose | 关闭时销毁 Drawer 里的子元素 | boolean | false | | forceRender | 预渲染 Drawer 内元素 | boolean | false | | getContainer | 指定 Drawer 挂载的 HTML 节点, false 为挂载在当前 dom | HTMLElement \| `() => HTMLElement` \| Selectors \| false | 'body' | diff --git a/components/dropdown/__tests__/__snapshots__/demo.test.js.snap b/components/dropdown/__tests__/__snapshots__/demo.test.js.snap index f880c6c956..cb88c19e00 100644 --- a/components/dropdown/__tests__/__snapshots__/demo.test.js.snap +++ b/components/dropdown/__tests__/__snapshots__/demo.test.js.snap @@ -1,5 +1,59 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`renders ./components/dropdown/demo/arrow.md correctly 1`] = ` +Array [ + , + , + , +
    , + , + , + , +] +`; + exports[`renders ./components/dropdown/demo/basic.md correctly 1`] = ` + + + 1st menu item + + + + + 2nd menu item + + + + + 3rd menu item + + + +); + +ReactDOM.render( + <> + + + + + + + + + +
    + + + + + + + + + + , + mountNode, +); +``` + +```css +#components-dropdown-demo-arrow .ant-btn { + margin-right: 8px; + margin-bottom: 8px; +} +.ant-row-rtl #components-dropdown-demo-arrow .ant-btn { + margin-right: 0; + margin-bottom: 8px; + margin-left: 8px; +} +``` diff --git a/components/dropdown/dropdown.tsx b/components/dropdown/dropdown.tsx index 7c768577b5..5b972add30 100644 --- a/components/dropdown/dropdown.tsx +++ b/components/dropdown/dropdown.tsx @@ -35,6 +35,7 @@ type Align = { }; export interface DropDownProps { + arrow?: boolean; trigger?: ('click' | 'hover' | 'contextMenu')[]; overlay: React.ReactElement | OverlayFunc; onVisibleChange?: (visible: boolean) => void; @@ -130,6 +131,7 @@ const Dropdown: DropdownInterface = props => { }; const { + arrow, prefixCls: customizePrefixCls, children, trigger, @@ -160,6 +162,7 @@ const Dropdown: DropdownInterface = props => { return ( document.body` | | | overlay | The dropdown menu | [Menu](/components/menu) \| () => Menu | - | | | overlayClassName | Class name of the dropdown root element | string | - | | diff --git a/components/dropdown/index.zh-CN.md b/components/dropdown/index.zh-CN.md index 82b5afbdb0..92a1caefb8 100644 --- a/components/dropdown/index.zh-CN.md +++ b/components/dropdown/index.zh-CN.md @@ -21,7 +21,8 @@ cover: https://gw.alipayobjects.com/zos/alicdn/eedWN59yJ/Dropdown.svg | 参数 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | -| disabled | 菜单是否禁用 | boolean | false | | +| arrow | 下拉框箭头是否显示 | boolean | false | | +| disabled | 菜单是否禁用 | boolean | - | | | getPopupContainer | 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。[示例](https://codepen.io/afc163/pen/zEjNOy?editors=0010) | Function(triggerNode) | `() => document.body` | | | overlay | 菜单 | [Menu](/components/menu) \| () => Menu | - | | | overlayClassName | 下拉根元素的类名称 | string | - | | diff --git a/components/dropdown/style/index.less b/components/dropdown/style/index.less index cb47c6a945..b977cde04f 100644 --- a/components/dropdown/style/index.less +++ b/components/dropdown/style/index.less @@ -47,6 +47,76 @@ display: none; } + // Offset the popover to account for the dropdown arrow + &-show-arrow&-placement-topCenter, + &-show-arrow&-placement-topLeft, + &-show-arrow&-placement-topRight { + padding-bottom: @popover-distance; + } + + &-show-arrow&-placement-bottomCenter, + &-show-arrow&-placement-bottomLeft, + &-show-arrow&-placement-bottomRight { + padding-top: @popover-distance; + } + + // Arrows + // .popover-arrow is outer, .popover-arrow:after is inner + + &-arrow { + position: absolute; + z-index: 1; // lift it up so the menu wouldn't cask shadow on it + display: block; + width: sqrt(@popover-arrow-width * @popover-arrow-width * 2); + height: sqrt(@popover-arrow-width * @popover-arrow-width * 2); + background: transparent; + border-style: solid; + border-width: sqrt(@popover-arrow-width * @popover-arrow-width * 2) / 2; + transform: rotate(45deg); + } + + &-placement-topCenter > &-arrow, + &-placement-topLeft > &-arrow, + &-placement-topRight > &-arrow { + bottom: @popover-distance - @popover-arrow-width + 2.2px; + border-top-color: transparent; + border-right-color: @popover-bg; + border-bottom-color: @popover-bg; + border-left-color: transparent; + box-shadow: 3px 3px 7px fade(@black, 7%); + } + &-placement-topCenter > &-arrow { + left: 50%; + transform: translateX(-50%) rotate(45deg); + } + &-placement-topLeft > &-arrow { + left: 16px; + } + &-placement-topRight > &-arrow { + right: 16px; + } + + &-placement-bottomCenter > &-arrow, + &-placement-bottomLeft > &-arrow, + &-placement-bottomRight > &-arrow { + top: @popover-distance - @popover-arrow-width + 2px; + border-top-color: @popover-bg; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: @popover-bg; + box-shadow: -2px -2px 5px fade(@black, 6%); + } + &-placement-bottomCenter > &-arrow { + left: 50%; + transform: translateX(-50%) rotate(45deg); + } + &-placement-bottomLeft > &-arrow { + left: 16px; + } + &-placement-bottomRight > &-arrow { + right: 16px; + } + &-menu { position: relative; margin: 0; diff --git a/components/form/Form.tsx b/components/form/Form.tsx index 75f788750e..2f884d403c 100644 --- a/components/form/Form.tsx +++ b/components/form/Form.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import omit from 'omit.js'; import classNames from 'classnames'; import FieldForm, { List } from 'rc-field-form'; import { FormProps as RcFormProps } from 'rc-field-form/lib/Form'; @@ -8,7 +7,7 @@ import { ColProps } from '../grid/col'; import { ConfigContext, ConfigConsumerProps } from '../config-provider'; import { FormContext } from './context'; import { FormLabelAlign } from './interface'; -import { useForm, FormInstance } from './util'; +import useForm, { FormInstance } from './hooks/useForm'; import SizeContext, { SizeType, SizeContextProvider } from '../config-provider/SizeContext'; export type FormLayout = 'horizontal' | 'inline' | 'vertical'; @@ -31,21 +30,24 @@ const InternalForm: React.ForwardRefRenderFunction = (props, const contextSize = React.useContext(SizeContext); const { getPrefixCls, direction }: ConfigConsumerProps = React.useContext(ConfigContext); + const { name } = props; + const { + prefixCls: customizePrefixCls, + className = '', + size = contextSize, form, colon, - name, labelAlign, labelCol, wrapperCol, - prefixCls: customizePrefixCls, hideRequiredMark, - className = '', layout = 'horizontal', - size = contextSize, scrollToFirstError, onFinishFailed, + ...restFormProps } = props; + const prefixCls = getPrefixCls('form', customizePrefixCls); const formClassName = classNames( @@ -59,20 +61,9 @@ const InternalForm: React.ForwardRefRenderFunction = (props, className, ); - const formProps = omit(props, [ - 'prefixCls', - 'className', - 'layout', - 'hideRequiredMark', - 'wrapperCol', - 'labelAlign', - 'labelCol', - 'colon', - 'scrollToFirstError', - ]); - const [wrapForm] = useForm(form); - wrapForm.__INTERNAL__.name = name; + const { __INTERNAL__ } = wrapForm; + __INTERNAL__.name = name; const formContextValue = React.useMemo( () => ({ @@ -82,6 +73,7 @@ const InternalForm: React.ForwardRefRenderFunction = (props, wrapperCol, vertical: layout === 'vertical', colon, + itemRef: __INTERNAL__.itemRef, }), [name, labelAlign, labelCol, wrapperCol, layout, colon], ); @@ -100,12 +92,10 @@ const InternalForm: React.ForwardRefRenderFunction = (props, return ( - + (validateStatus); @@ -97,7 +102,6 @@ function FormItem(props: FormItemProps): React.ReactElement { } } - const { name: formName } = formContext; const hasName = hasValidName(name); // Cache Field NamePath @@ -126,6 +130,9 @@ function FormItem(props: FormItemProps): React.ReactElement { } }; + // ===================== Children Ref ===================== + const getItemRef = useItemRef(); + function renderLayout( baseChildren: React.ReactNode, fieldId?: string, @@ -179,6 +186,7 @@ function FormItem(props: FormItemProps): React.ReactElement { [`${prefixCls}-item-has-error-leave`]: !help && domErrorVisible && prevValidateStatusRef.current === 'error', [`${prefixCls}-item-is-validating`]: mergedValidateStatus === 'validating', + [`${prefixCls}-hidden`]: hidden, }; // ======================= Children ======================= @@ -323,6 +331,10 @@ function FormItem(props: FormItemProps): React.ReactElement { childProps.id = fieldId; } + if (supportRef(children)) { + childProps.ref = getItemRef(mergedName, children); + } + // We should keep user origin event handler const triggers = new Set([ ...toArray(trigger), diff --git a/components/form/FormItemInput.tsx b/components/form/FormItemInput.tsx index 4690b3e549..039c445f69 100644 --- a/components/form/FormItemInput.tsx +++ b/components/form/FormItemInput.tsx @@ -10,7 +10,7 @@ import CSSMotion from 'rc-animate/lib/CSSMotion'; import Col, { ColProps } from '../grid/col'; import { ValidateStatus } from './FormItem'; import { FormContext } from './context'; -import { useCacheErrors } from './util'; +import useCacheErrors from './hooks/useCacheErrors'; interface FormItemInputMiscProps { prefixCls: string; diff --git a/components/form/__tests__/__snapshots__/demo.test.js.snap b/components/form/__tests__/__snapshots__/demo.test.js.snap index cb29c5c3bf..f5c7fbc3d7 100644 --- a/components/form/__tests__/__snapshots__/demo.test.js.snap +++ b/components/form/__tests__/__snapshots__/demo.test.js.snap @@ -2196,6 +2196,84 @@ exports[`renders ./components/form/demo/normal-login.md correctly 1`] = ` `; +exports[`renders ./components/form/demo/ref-item.md correctly 1`] = ` +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +`; + exports[`renders ./components/form/demo/register.md correctly 1`] = ` +`; + exports[`Form Form.Item should support data-*、aria-* and custom attribute 1`] = `
    { expect(wrapper.find('input').prop('onBlur')).toBeTruthy(); }); + + it('Form item hidden', () => { + const wrapper = mount( + + +
    , + ); + expect(wrapper).toMatchRenderedSnapshot(); + }); }); diff --git a/components/form/__tests__/ref.test.tsx b/components/form/__tests__/ref.test.tsx new file mode 100644 index 0000000000..1fee1ba93d --- /dev/null +++ b/components/form/__tests__/ref.test.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/jsx-key */ + +import React from 'react'; +import { mount } from 'enzyme'; +import Form from '..'; +import Input from '../../input'; +import Button from '../../button'; + +describe('Form.Ref', () => { + const Test = ({ + onRef, + show, + }: { + onRef: (node: React.ReactElement, originRef: React.RefObject) => void; + show?: boolean; + }) => { + const [form] = Form.useForm(); + const removeRef = React.useRef(); + const testRef = React.useRef(); + const listRef = React.useRef(); + + return ( +
    + {show && ( + + + + )} + + + + + + + {fields => + fields.map(field => ( + + + + )) + } + + + + + +
    + ); + }; + + it('should ref work', () => { + const onRef = jest.fn(); + const wrapper = mount(); + + wrapper.find('.ref-item').last().simulate('click'); + expect(onRef).toHaveBeenCalled(); + expect(onRef.mock.calls[0][0]).toBe(onRef.mock.calls[0][1]); + + onRef.mockReset(); + wrapper.find('.ref-list').last().simulate('click'); + expect(onRef).toHaveBeenCalled(); + expect(onRef.mock.calls[0][0]).toBe(onRef.mock.calls[0][1]); + + onRef.mockReset(); + wrapper.setProps({ show: false }); + wrapper.update(); + wrapper.find('.ref-remove').last().simulate('click'); + expect(onRef).toHaveBeenCalledWith(undefined, null); + }); +}); diff --git a/components/form/context.tsx b/components/form/context.tsx index 73dfb8c3a2..f87fc786c1 100644 --- a/components/form/context.tsx +++ b/components/form/context.tsx @@ -16,11 +16,13 @@ export interface FormContextProps { labelAlign?: FormLabelAlign; labelCol?: ColProps; wrapperCol?: ColProps; + itemRef: (name: (string | number)[]) => (node: React.ReactElement) => void; } export const FormContext = React.createContext({ labelAlign: 'right', vertical: false, + itemRef: (() => {}) as any, }); /** diff --git a/components/form/demo/ref-item.md b/components/form/demo/ref-item.md new file mode 100644 index 0000000000..dadd38ce13 --- /dev/null +++ b/components/form/demo/ref-item.md @@ -0,0 +1,61 @@ +--- +order: 999999 +title: + zh-CN: 引用字段 + en-US: Ref item +debug: true +--- + +## zh-CN + +请优先使用 `ref`! + +## en-US + +Use `ref` first! + +```jsx +import React from 'react'; +import { Button, Form, Input } from 'antd'; + +const Demo = () => { + const [form] = Form.useForm(); + const ref = React.useRef(); + + return ( +
    + + + + + + {fields => + fields.map(field => ( + + + + )) + } + + + + +
    + ); +}; + +ReactDOM.render(, mountNode); +``` diff --git a/components/form/hooks/useCacheErrors.ts b/components/form/hooks/useCacheErrors.ts new file mode 100644 index 0000000000..149841f5e6 --- /dev/null +++ b/components/form/hooks/useCacheErrors.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; + +/** + * Always debounce error to avoid [error -> null -> error] blink + */ +export default function useCacheErrors( + errors: React.ReactNode[], + changeTrigger: (visible: boolean) => void, + directly: boolean, +): [boolean, React.ReactNode[]] { + const cacheRef = React.useRef({ + errors, + visible: !!errors.length, + }); + + const [, forceUpdate] = React.useState({}); + + const update = () => { + const prevVisible = cacheRef.current.visible; + const newVisible = !!errors.length; + + const prevErrors = cacheRef.current.errors; + cacheRef.current.errors = errors; + cacheRef.current.visible = newVisible; + + if (prevVisible !== newVisible) { + changeTrigger(newVisible); + } else if ( + prevErrors.length !== errors.length || + prevErrors.some((prevErr, index) => prevErr !== errors[index]) + ) { + forceUpdate({}); + } + }; + + React.useEffect(() => { + if (!directly) { + const timeout = setTimeout(update, 10); + return () => clearTimeout(timeout); + } + }, [errors]); + + if (directly) { + update(); + } + + return [cacheRef.current.visible, cacheRef.current.errors]; +} diff --git a/components/form/hooks/useForm.ts b/components/form/hooks/useForm.ts new file mode 100644 index 0000000000..521d16eacd --- /dev/null +++ b/components/form/hooks/useForm.ts @@ -0,0 +1,64 @@ +import { useRef, useMemo } from 'react'; +import { useForm as useRcForm, FormInstance as RcFormInstance } from 'rc-field-form'; +import scrollIntoView from 'scroll-into-view-if-needed'; +import { ScrollOptions, NamePath, InternalNamePath } from '../interface'; +import { toArray, getFieldId } from '../util'; + +export interface FormInstance extends RcFormInstance { + scrollToField: (name: NamePath, options?: ScrollOptions) => void; + /** This is an internal usage. Do not use in your prod */ + __INTERNAL__: { + /** No! Do not use this in your code! */ + name?: string; + /** No! Do not use this in your code! */ + itemRef: (name: InternalNamePath) => (node: React.ReactElement) => void; + }; + getFieldInstance: (name: NamePath) => any; +} + +function toNamePathStr(name: NamePath) { + const namePath = toArray(name); + return namePath.join('_'); +} + +export default function useForm(form?: FormInstance): [FormInstance] { + const [rcForm] = useRcForm(); + const itemsRef = useRef>({}); + + const wrapForm: FormInstance = useMemo( + () => + form || { + ...rcForm, + __INTERNAL__: { + itemRef: (name: InternalNamePath) => (node: React.ReactElement) => { + const namePathStr = toNamePathStr(name); + if (node) { + itemsRef.current[namePathStr] = node; + } else { + delete itemsRef.current[namePathStr]; + } + }, + }, + scrollToField: (name: string, options: ScrollOptions = {}) => { + const namePath = toArray(name); + const fieldId = getFieldId(namePath, wrapForm.__INTERNAL__.name); + const node: HTMLElement | null = fieldId ? document.getElementById(fieldId) : null; + + if (node) { + scrollIntoView(node, { + scrollMode: 'if-needed', + block: 'nearest', + ...options, + }); + } + }, + getFieldInstance: (name: string) => { + const namePathStr = toNamePathStr(name); + return itemsRef.current[namePathStr]; + }, + }, + [form, rcForm], + ); + + return [wrapForm]; +} diff --git a/components/form/hooks/useFrameState.ts b/components/form/hooks/useFrameState.ts new file mode 100644 index 0000000000..10d2bd4af1 --- /dev/null +++ b/components/form/hooks/useFrameState.ts @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { useRef } from 'react'; +import raf from 'raf'; + +type Updater = (prev?: ValueType) => ValueType; + +export default function useFrameState( + defaultValue: ValueType, +): [ValueType, (updater: Updater) => void] { + const [value, setValue] = React.useState(defaultValue); + const frameRef = useRef(null); + const batchRef = useRef[]>([]); + const destroyRef = useRef(false); + + React.useEffect( + () => () => { + destroyRef.current = true; + raf.cancel(frameRef.current!); + }, + [], + ); + + function setFrameValue(updater: Updater) { + if (destroyRef.current) { + return; + } + + if (frameRef.current === null) { + batchRef.current = []; + frameRef.current = raf(() => { + frameRef.current = null; + setValue(prevValue => { + let current = prevValue; + + batchRef.current.forEach(func => { + current = func(current); + }); + + return current; + }); + }); + } + + batchRef.current.push(updater); + } + + return [value, setFrameValue]; +} diff --git a/components/form/hooks/useItemRef.ts b/components/form/hooks/useItemRef.ts new file mode 100644 index 0000000000..1e96ef8cb5 --- /dev/null +++ b/components/form/hooks/useItemRef.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { composeRef } from 'rc-util/lib/ref'; +import { FormContext } from '../context'; +import { InternalNamePath } from '../interface'; + +export default function useItemRef() { + const { itemRef } = React.useContext(FormContext); + const cacheRef = React.useRef<{ + name?: string; + originRef?: React.Ref; + ref?: React.Ref; + }>({}); + + function getRef(name: InternalNamePath, children: any) { + const childrenRef: React.Ref = + children && typeof children === 'object' && children.ref; + const nameStr = name.join('_'); + if (cacheRef.current.name !== nameStr || cacheRef.current.originRef !== childrenRef) { + cacheRef.current.name = nameStr; + cacheRef.current.originRef = childrenRef; + cacheRef.current.ref = composeRef(itemRef(name), childrenRef); + } + + return cacheRef.current.ref; + } + + return getRef; +} diff --git a/components/form/index.en-US.md b/components/form/index.en-US.md index 269b48f832..6c84bf98de 100644 --- a/components/form/index.en-US.md +++ b/components/form/index.en-US.md @@ -29,6 +29,7 @@ High performance Form component with data scope management. Including data colle | labelCol | label layout, like `` component. Set `span` `offset` value like `{span: 3, offset: 12}` or `sm: {span: 3, offset: 12}` | [object](/components/grid/#Col) | - | | | layout | Form layout | `horizontal` \| `vertical` \| `inline` | `horizontal` | | | name | Form name. Will be the prefix of Field `id` | string | - | | +| preserve | Keep field value even when field removed | boolean | true | 4.4.0 | | scrollToFirstError | Auto scroll to first failed field when submit | boolean | false | | | size | Set field component size (antd components only) | `small` \| `middle` \| `large` | - | | | validateMessages | Validation prompt template, description [see below](#validateMessages) | [ValidateMessages](https://github.com/react-component/field-form/blob/master/src/utils/messages.ts) | - | | @@ -86,6 +87,7 @@ Form field component for data bidirectional binding, validation, layout, and so | labelCol | The layout of label. You can set `span` `offset` to something like `{span: 3, offset: 12}` or `sm: {span: 3, offset: 12}` same as with ``. You can set `labelCol` on Form. If both exists, use Item first | [object](/components/grid/#Col) | - | | | name | Field name, support array | [NamePath](#NamePath) | - | | | normalize | Normalize value from component value before passing to Form instance | (value, prevValue, prevValues) => any | - | | +| preserve | Keep field value even when field removed | boolean | true | 4.4.0 | | required | Display required style. It will be generated by the validation rule | boolean | false | | | rules | Rules for field validation. Click [here](#components-form-demo-basic) to see an example | [Rule](#Rule)[] | - | | | shouldUpdate | Custom field update logic. See [below](#shouldUpdate) | boolean \| (prevValue, curValue) => boolean | false | | @@ -95,6 +97,7 @@ Form field component for data bidirectional binding, validation, layout, and so | validateTrigger | When to validate the value of children node | string \| string[] | `onChange` | | | valuePropName | Props of children node, for example, the prop of Switch is 'checked'. This prop is an encapsulation of `getValueProps`, which will be invalid after customizing `getValueProps` | string | `value` | | | wrapperCol | The layout for input controls, same as `labelCol`. You can set `wrapperCol` on Form. If both exists, use Item first | [object](/components/grid/#Col) | - | | +| hidden | whether to hide Form.Item (still collect and validate value) | boolean | false | | After wrapped by `Form.Item` with `name` property, `value`(or other property defined by `valuePropName`) `onChange`(or other property defined by `trigger`) props will be added to form controls, the flow of form data will be handled by Form which will cause: @@ -185,21 +188,22 @@ Provide linkage between forms. If a sub form with `name` prop update, it will au ### FormInstance -| Name | Description | Type | -| --- | --- | --- | -| getFieldValue | Get the value by the field name | (name: [NamePath](#NamePath)) => any | -| getFieldsValue | Get values by a set of field names. Return according to the corresponding structure | (nameList?: [NamePath](#NamePath)[], filterFunc?: (meta: { touched: boolean, validating: boolean }) => boolean) => any | -| getFieldError | Get the error messages by the field name | (name: [NamePath](#NamePath)) => string[] | -| getFieldsError | Get the error messages by the fields name. Return as an array | (nameList?: [NamePath](#NamePath)[]) => FieldError[] | -| isFieldTouched | Check if a field has been operated | (name: [NamePath](#NamePath)) => boolean | -| isFieldsTouched | Check if fields have been operated. Check if all fields is touched when `allTouched` is `true` | (nameList?: [NamePath](#NamePath)[], allTouched?: boolean) => boolean | -| isFieldValidating | Check fields if is in validating | (name: [NamePath](#NamePath)) => boolean | -| resetFields | Reset fields to `initialValues` | (fields?: [NamePath](#NamePath)[]) => void | -| scrollToField | Scroll to field position | (name: [NamePath](#NamePath), options: [[ScrollOptions](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options)]) => void | -| setFields | Set fields status | (fields: [FieldData](#FieldData)[]) => void | -| setFieldsValue | Set fields value | (values) => void | -| submit | Submit the form. It's same as click `submit` button | () => void | -| validateFields | Validate fields | (nameList?: [NamePath](#NamePath)[]) => Promise | +| Name | Description | Type | Version | +| --- | --- | --- | --- | +| getFieldInstance | Get field instance | (name: [NamePath](#NamePath)) => any | 4.4.0 | +| getFieldValue | Get the value by the field name | (name: [NamePath](#NamePath)) => any | | +| getFieldsValue | Get values by a set of field names. Return according to the corresponding structure | (nameList?: [NamePath](#NamePath)[], filterFunc?: (meta: { touched: boolean, validating: boolean }) => boolean) => any | | +| getFieldError | Get the error messages by the field name | (name: [NamePath](#NamePath)) => string[] | | +| getFieldsError | Get the error messages by the fields name. Return as an array | (nameList?: [NamePath](#NamePath)[]) => FieldError[] | | +| isFieldTouched | Check if a field has been operated | (name: [NamePath](#NamePath)) => boolean | | +| isFieldsTouched | Check if fields have been operated. Check if all fields is touched when `allTouched` is `true` | (nameList?: [NamePath](#NamePath)[], allTouched?: boolean) => boolean | | +| isFieldValidating | Check fields if is in validating | (name: [NamePath](#NamePath)) => boolean | | +| resetFields | Reset fields to `initialValues` | (fields?: [NamePath](#NamePath)[]) => void | | +| scrollToField | Scroll to field position | (name: [NamePath](#NamePath), options: [[ScrollOptions](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options)]) => void | | +| setFields | Set fields status | (fields: [FieldData](#FieldData)[]) => void | | +| setFieldsValue | Set fields value | (values) => void | | +| submit | Submit the form. It's same as click `submit` button | () => void | | +| validateFields | Validate fields | (nameList?: [NamePath](#NamePath)[]) => Promise | | #### validateFields return sample diff --git a/components/form/index.zh-CN.md b/components/form/index.zh-CN.md index ab2841a3f7..7d94e5f7b6 100644 --- a/components/form/index.zh-CN.md +++ b/components/form/index.zh-CN.md @@ -30,6 +30,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/ORmcdeaoO/Form.svg | labelCol | label 标签布局,同 `` 组件,设置 `span` `offset` 值,如 `{span: 3, offset: 12}` 或 `sm: {span: 3, offset: 12}` | [object](/components/grid/#Col) | - | | | layout | 表单布局 | `horizontal` \| `vertical` \| `inline` | `horizontal` | | | name | 表单名称,会作为表单字段 `id` 前缀使用 | string | - | | +| preserve | 当字段被删除时保留字段值 | boolean | true | 4.4.0 | | scrollToFirstError | 提交失败自动滚动到第一个错误字段 | boolean | false | | | size | 设置字段组件的尺寸(仅限 antd 组件) | `small` \| `middle` \| `large` | - | | | validateMessages | 验证提示模板,说明[见下](#validateMessages) | [ValidateMessages](https://github.com/react-component/field-form/blob/master/src/utils/messages.ts) | - | | @@ -86,6 +87,7 @@ const validateMessages = { | labelAlign | 标签文本对齐方式 | `left` \| `right` | `right` | | | labelCol | `label` 标签布局,同 `` 组件,设置 `span` `offset` 值,如 `{span: 3, offset: 12}` 或 `sm: {span: 3, offset: 12}`。你可以通过 Form 的 `labelCol` 进行统一设置。当和 Form 同时设置时,以 Item 为准 | [object](/components/grid/#Col) | - | | | name | 字段名,支持数组 | [NamePath](#NamePath) | - | | +| preserve | 当字段被删除时保留字段值 | boolean | true | 4.4.0 | | normalize | 组件获取值后进行转换,再放入 Form 中 | (value, prevValue, prevValues) => any | - | | | required | 必填样式设置。如不设置,则会根据校验规则自动生成 | boolean | false | | | rules | 校验规则,设置字段的校验逻辑。点击[此处](#components-form-demo-basic)查看示例 | [Rule](#Rule)[] | - | | @@ -96,6 +98,7 @@ const validateMessages = { | validateTrigger | 设置字段校验的时机 | string \| string[] | `onChange` | | | valuePropName | 子节点的值的属性,如 Switch 的是 'checked'。该属性为 `getValueProps` 的封装,自定义 `getValueProps` 后会失效 | string | `value` | | | wrapperCol | 需要为输入控件设置布局样式时,使用该属性,用法同 `labelCol`。你可以通过 Form 的 `wrapperCol` 进行统一设置。当和 Form 同时设置时,以 Item 为准。 | [object](/components/grid/#Col) | - | | +| hidden | 是否隐藏字段(依然会收集和校验字段) | boolean | false | | 被设置了 `name` 属性的 `Form.Item` 包装的控件,表单控件会自动添加 `value`(或 `valuePropName` 指定的其他属性) `onChange`(或 `trigger` 指定的其他属性),数据同步将被 Form 接管,这会导致以下结果: @@ -186,21 +189,22 @@ Form 通过增量更新方式,只更新被修改的字段相关组件以达到 ### FormInstance -| 名称 | 说明 | 类型 | -| --- | --- | --- | -| getFieldValue | 获取对应字段名的值 | (name: [NamePath](#NamePath)) => any | -| getFieldsValue | 获取一组字段名对应的值,会按照对应结构返回 | (nameList?: [NamePath](#NamePath)[], filterFunc?: (meta: { touched: boolean, validating: boolean }) => boolean) => any | -| getFieldError | 获取对应字段名的错误信息 | (name: [NamePath](#NamePath)) => string[] | -| getFieldsError | 获取一组字段名对应的错误信息,返回为数组形式 | (nameList?: [NamePath](#NamePath)[]) => FieldError[] | -| isFieldTouched | 检查对应字段是否被用户操作过 | (name: [NamePath](#NamePath)) => boolean | -| isFieldsTouched | 检查一组字段是否被用户操作过,`allTouched` 为 `true` 时检查是否所有字段都被操作过 | (nameList?: [NamePath](#NamePath)[], allTouched?: boolean) => boolean | -| isFieldValidating | 检查一组字段是否正在校验 | (name: [NamePath](#NamePath)) => boolean | -| resetFields | 重置一组字段到 `initialValues` | (fields?: [NamePath](#NamePath)[]) => void | -| scrollToField | 滚动到对应字段位置 | (name: [NamePath](#NamePath), options: [[ScrollOptions](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options)]) => void | -| setFields | 设置一组字段状态 | (fields: [FieldData](#FieldData)[]) => void | -| setFieldsValue | 设置表单的值 | (values) => void | -| submit | 提交表单,与点击 `submit` 按钮效果相同 | () => void | -| validateFields | 触发表单验证 | (nameList?: [NamePath](#NamePath)[]) => Promise | +| 名称 | 说明 | 类型 | 版本 | +| --- | --- | --- | --- | +| getFieldInstance | 获取对应字段示例 | (name: [NamePath](#NamePath)) => any | 4.4.0 | +| getFieldValue | 获取对应字段名的值 | (name: [NamePath](#NamePath)) => any | | +| getFieldsValue | 获取一组字段名对应的值,会按照对应结构返回 | (nameList?: [NamePath](#NamePath)[], filterFunc?: (meta: { touched: boolean, validating: boolean }) => boolean) => any | | +| getFieldError | 获取对应字段名的错误信息 | (name: [NamePath](#NamePath)) => string[] | | +| getFieldsError | 获取一组字段名对应的错误信息,返回为数组形式 | (nameList?: [NamePath](#NamePath)[]) => FieldError[] | | +| isFieldTouched | 检查对应字段是否被用户操作过 | (name: [NamePath](#NamePath)) => boolean | | +| isFieldsTouched | 检查一组字段是否被用户操作过,`allTouched` 为 `true` 时检查是否所有字段都被操作过 | (nameList?: [NamePath](#NamePath)[], allTouched?: boolean) => boolean | | +| isFieldValidating | 检查一组字段是否正在校验 | (name: [NamePath](#NamePath)) => boolean | | +| resetFields | 重置一组字段到 `initialValues` | (fields?: [NamePath](#NamePath)[]) => void | | +| scrollToField | 滚动到对应字段位置 | (name: [NamePath](#NamePath), options: [[ScrollOptions](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options)]) => void | | +| setFields | 设置一组字段状态 | (fields: [FieldData](#FieldData)[]) => void | | +| setFieldsValue | 设置表单的值 | (values) => void | | +| submit | 提交表单,与点击 `submit` 按钮效果相同 | () => void | | +| validateFields | 触发表单验证 | (nameList?: [NamePath](#NamePath)[]) => Promise | | #### validateFields 返回示例 diff --git a/components/form/interface.ts b/components/form/interface.ts index f9ec7a0e76..eae8bf49b5 100644 --- a/components/form/interface.ts +++ b/components/form/interface.ts @@ -1,3 +1,3 @@ export { Options as ScrollOptions } from 'scroll-into-view-if-needed'; export type FormLabelAlign = 'left' | 'right'; -export { Store, StoreValue } from 'rc-field-form/lib/interface'; +export { Store, StoreValue, NamePath, InternalNamePath } from 'rc-field-form/lib/interface'; diff --git a/components/form/style/index.less b/components/form/style/index.less index 51fa0a93ee..7bff52228a 100644 --- a/components/form/style/index.less +++ b/components/form/style/index.less @@ -66,6 +66,10 @@ margin-bottom: 0; } + &-hidden { + display: none; + } + // ============================================================== // = Label = // ============================================================== diff --git a/components/form/util.ts b/components/form/util.ts index 93fd896890..d32067fb33 100644 --- a/components/form/util.ts +++ b/components/form/util.ts @@ -1,61 +1,4 @@ -import * as React from 'react'; -import raf from 'raf'; -import { useForm as useRcForm, FormInstance as RcFormInstance } from 'rc-field-form'; -import scrollIntoView from 'scroll-into-view-if-needed'; -import { ScrollOptions } from './interface'; - -type InternalNamePath = (string | number)[]; - -/** - * Always debounce error to avoid [error -> null -> error] blink - */ -export function useCacheErrors( - errors: React.ReactNode[], - changeTrigger: (visible: boolean) => void, - directly: boolean, -): [boolean, React.ReactNode[]] { - const cacheRef = React.useRef({ - errors, - visible: !!errors.length, - }); - - const [, forceUpdate] = React.useState({}); - - const update = (newErrors: React.ReactNode[]) => { - const prevVisible = cacheRef.current.visible; - const newVisible = !!newErrors.length; - - const prevErrors = cacheRef.current.errors; - cacheRef.current.errors = newErrors; - cacheRef.current.visible = newVisible; - - if (prevVisible !== newVisible) { - changeTrigger(newVisible); - } else if ( - prevErrors.length !== newErrors.length || - prevErrors.some((prevErr, index) => prevErr !== newErrors[index]) - ) { - forceUpdate({}); - } - }; - - React.useEffect(() => { - if (!directly) { - const timeout = setTimeout(() => { - update(errors); - }, 10); - return () => { - clearTimeout(timeout); - }; - } - }, [errors]); - - if (directly) { - update(errors); - } - - return [cacheRef.current.visible, cacheRef.current.errors]; -} +import { InternalNamePath } from './interface'; export function toArray(candidate?: T | T[] | false): T[] { if (candidate === undefined || candidate === false) return []; @@ -69,83 +12,3 @@ export function getFieldId(namePath: InternalNamePath, formName?: string): strin const mergedId = namePath.join('_'); return formName ? `${formName}_${mergedId}` : mergedId; } - -export interface FormInstance extends RcFormInstance { - scrollToField: (name: string | number | InternalNamePath, options?: ScrollOptions) => void; - __INTERNAL__: { - name?: string; - }; -} - -export function useForm(form?: FormInstance): [FormInstance] { - const [rcForm] = useRcForm(); - - const wrapForm: FormInstance = React.useMemo( - () => - form || { - ...rcForm, - __INTERNAL__: {}, - scrollToField: (name: string, options: ScrollOptions = {}) => { - const namePath = toArray(name); - const fieldId = getFieldId(namePath, wrapForm.__INTERNAL__.name); - const node: HTMLElement | null = fieldId ? document.getElementById(fieldId) : null; - - if (node) { - scrollIntoView(node, { - scrollMode: 'if-needed', - block: 'nearest', - ...options, - }); - } - }, - }, - [form, rcForm], - ); - - return [wrapForm]; -} - -type Updater = (prev?: ValueType) => ValueType; - -export function useFrameState( - defaultValue: ValueType, -): [ValueType, (updater: Updater) => void] { - const [value, setValue] = React.useState(defaultValue); - const frameRef = React.useRef(null); - const batchRef = React.useRef[]>([]); - const destroyRef = React.useRef(false); - - React.useEffect( - () => () => { - destroyRef.current = true; - raf.cancel(frameRef.current!); - }, - [], - ); - - function setFrameValue(updater: Updater) { - if (destroyRef.current) { - return; - } - - if (frameRef.current === null) { - batchRef.current = []; - frameRef.current = raf(() => { - frameRef.current = null; - setValue(prevValue => { - let current = prevValue; - - batchRef.current.forEach(func => { - current = func(current); - }); - - return current; - }); - }); - } - - batchRef.current.push(updater); - } - - return [value, setFrameValue]; -} diff --git a/components/grid/__tests__/__snapshots__/demo.test.js.snap b/components/grid/__tests__/__snapshots__/demo.test.js.snap index a0f3b42096..4b6ca60339 100644 --- a/components/grid/__tests__/__snapshots__/demo.test.js.snap +++ b/components/grid/__tests__/__snapshots__/demo.test.js.snap @@ -410,30 +410,78 @@ Array [ `; exports[`renders ./components/grid/demo/flex-order.md correctly 1`] = ` -
    +Array [ + + Normal + +
    ,
    - 2 col-order-3 -
    +
    + 1 col-order-4 +
    +
    + 2 col-order-3 +
    +
    + 3 col-order-2 +
    +
    + 4 col-order-1 +
    +
    , + + Responsive + + ,
    - 4 col-order-1 -
    - +
    + 1 col-order-esponsive +
    +
    + 2 col-order-esponsive +
    +
    + 3 col-order-esponsive +
    +
    + 4 col-order-esponsive +
    + , +] `; exports[`renders ./components/grid/demo/flex-stretch.md correctly 1`] = ` diff --git a/components/grid/demo/flex-order.md b/components/grid/demo/flex-order.md index ab6a985c93..04e50c3eb4 100644 --- a/components/grid/demo/flex-order.md +++ b/components/grid/demo/flex-order.md @@ -14,23 +14,45 @@ title: To change the element sort by order. ```jsx -import { Row, Col } from 'antd'; +import { Row, Col, Divider } from 'antd'; ReactDOM.render( - - - 1 col-order-4 - - - 2 col-order-3 - - - 3 col-order-2 - - - 4 col-order-1 - - , + <> + + Normal + + + + 1 col-order-4 + + + 2 col-order-3 + + + 3 col-order-2 + + + 4 col-order-1 + + + + Responsive + + + + 1 col-order-esponsive + + + 2 col-order-esponsive + + + 3 col-order-esponsive + + + 4 col-order-esponsive + + + , mountNode, ); ``` diff --git a/components/input/ResizableTextArea.tsx b/components/input/ResizableTextArea.tsx deleted file mode 100644 index 66e4c8a873..0000000000 --- a/components/input/ResizableTextArea.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import * as React from 'react'; -import ResizeObserver from 'rc-resize-observer'; -import omit from 'omit.js'; -import classNames from 'classnames'; -import calculateNodeHeight from './calculateNodeHeight'; -import raf from '../_util/raf'; -import { TextAreaProps } from './TextArea'; - -const RESIZE_STATUS_NONE = 0; -const RESIZE_STATUS_RESIZING = 1; -const RESIZE_STATUS_RESIZED = 2; - -export interface AutoSizeType { - minRows?: number; - maxRows?: number; -} - -export interface TextAreaState { - textareaStyles?: React.CSSProperties; - /** We need add process style to disable scroll first and then add back to avoid unexpected scrollbar */ - resizeStatus?: - | typeof RESIZE_STATUS_NONE - | typeof RESIZE_STATUS_RESIZING - | typeof RESIZE_STATUS_RESIZED; -} - -class ResizableTextArea extends React.Component { - nextFrameActionId: number; - - resizeFrameId: number; - - constructor(props: TextAreaProps) { - super(props); - this.state = { - textareaStyles: {}, - resizeStatus: RESIZE_STATUS_NONE, - }; - } - - textArea: HTMLTextAreaElement; - - saveTextArea = (textArea: HTMLTextAreaElement) => { - this.textArea = textArea; - }; - - componentDidMount() { - this.resizeTextarea(); - } - - componentDidUpdate(prevProps: TextAreaProps) { - // Re-render with the new content then recalculate the height as required. - if (prevProps.value !== this.props.value) { - this.resizeTextarea(); - } - } - - handleResize = (size: { width: number; height: number }) => { - const { resizeStatus } = this.state; - const { autoSize, onResize } = this.props; - - if (resizeStatus !== RESIZE_STATUS_NONE) { - return; - } - - if (typeof onResize === 'function') { - onResize(size); - } - if (autoSize) { - this.resizeOnNextFrame(); - } - }; - - resizeOnNextFrame = () => { - raf.cancel(this.nextFrameActionId); - this.nextFrameActionId = raf(this.resizeTextarea); - }; - - resizeTextarea = () => { - const { autoSize } = this.props; - if (!autoSize || !this.textArea) { - return; - } - const { minRows, maxRows } = autoSize as AutoSizeType; - const textareaStyles = calculateNodeHeight(this.textArea, false, minRows, maxRows); - this.setState({ textareaStyles, resizeStatus: RESIZE_STATUS_RESIZING }, () => { - raf.cancel(this.resizeFrameId); - this.resizeFrameId = raf(() => { - this.setState({ resizeStatus: RESIZE_STATUS_RESIZED }, () => { - this.resizeFrameId = raf(() => { - this.setState({ resizeStatus: RESIZE_STATUS_NONE }); - this.fixFirefoxAutoScroll(); - }); - }); - }); - }); - }; - - componentWillUnmount() { - raf.cancel(this.nextFrameActionId); - raf.cancel(this.resizeFrameId); - } - - // https://github.com/ant-design/ant-design/issues/21870 - fixFirefoxAutoScroll() { - try { - if (document.activeElement === this.textArea) { - const currentStart = this.textArea.selectionStart; - const currentEnd = this.textArea.selectionEnd; - this.textArea.setSelectionRange(currentStart, currentEnd); - } - } catch (e) { - // Fix error in Chrome: - // Failed to read the 'selectionStart' property from 'HTMLInputElement' - // http://stackoverflow.com/q/21177489/3040605 - } - } - - renderTextArea = () => { - const { prefixCls, autoSize, onResize, className, disabled } = this.props; - const { textareaStyles, resizeStatus } = this.state; - const otherProps = omit(this.props, [ - 'prefixCls', - 'onPressEnter', - 'autoSize', - 'defaultValue', - 'allowClear', - 'onResize', - ]); - const cls = classNames(prefixCls, className, { - [`${prefixCls}-disabled`]: disabled, - }); - // Fix https://github.com/ant-design/ant-design/issues/6776 - // Make sure it could be reset when using form.getFieldDecorator - if ('value' in otherProps) { - otherProps.value = otherProps.value || ''; - } - const style = { - ...this.props.style, - ...textareaStyles, - ...(resizeStatus === RESIZE_STATUS_RESIZING - ? // React will warning when mix `overflow` & `overflowY`. - // We need to define this separately. - { overflowX: 'hidden', overflowY: 'hidden' } - : null), - }; - return ( - -