feat: Modal support hooks (#20949)

* init hooks

* portal it in

* children with 2 context demo

* Remove config since not more second demo

* Quit with same Component for logic reuse

* add rest functions

* use localeReceiver

* use localeReceiver

* update test case

* fix lint

* update docs

* update demo title
This commit is contained in:
二货机器人 2020-02-03 13:00:35 +08:00 committed by GitHub
parent b36e96043f
commit d02c486052
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 522 additions and 186 deletions

View File

@ -0,0 +1,18 @@
import * as React from 'react';
export default function usePatchElement(): [
React.ReactElement[],
(element: React.ReactElement) => Function,
] {
const [elements, setElements] = React.useState<React.ReactElement[]>([]);
function patchElement(element: React.ReactElement) {
setElements(originElements => [...originElements, element]);
return () => {
setElements(originElements => originElements.filter(ele => ele !== element));
};
}
return [elements, patchElement];
}

View File

@ -0,0 +1,117 @@
import * as React from 'react';
import classNames from 'classnames';
import Dialog, { ModalFuncProps } from './Modal';
import ActionButton from './ActionButton';
import warning from '../_util/warning';
interface ConfirmDialogProps extends ModalFuncProps {
afterClose?: () => void;
close: (...args: any[]) => void;
autoFocusButton?: null | 'ok' | 'cancel';
}
const ConfirmDialog = (props: ConfirmDialogProps) => {
const {
icon,
onCancel,
onOk,
close,
zIndex,
afterClose,
visible,
keyboard,
centered,
getContainer,
maskStyle,
okText,
okButtonProps,
cancelText,
cancelButtonProps,
} = props;
warning(
!(typeof icon === 'string' && icon.length > 2),
'Modal',
`\`icon\` is using ReactNode instead of string naming in v4. Please check \`${icon}\` at https://ant.design/components/icon`,
);
// 支持传入{ icon: null }来隐藏`Modal.confirm`默认的Icon
const okType = props.okType || 'primary';
const prefixCls = props.prefixCls || 'ant-modal';
const contentPrefixCls = `${prefixCls}-confirm`;
// 默认为 true保持向下兼容
const okCancel = 'okCancel' in props ? props.okCancel! : true;
const width = props.width || 416;
const style = props.style || {};
const mask = props.mask === undefined ? true : props.mask;
// 默认为 false保持旧版默认行为
const maskClosable = props.maskClosable === undefined ? false : props.maskClosable;
const autoFocusButton = props.autoFocusButton === null ? false : props.autoFocusButton || 'ok';
const transitionName = props.transitionName || 'zoom';
const maskTransitionName = props.maskTransitionName || 'fade';
const classString = classNames(
contentPrefixCls,
`${contentPrefixCls}-${props.type}`,
props.className,
);
const cancelButton = okCancel && (
<ActionButton
actionFn={onCancel}
closeModal={close}
autoFocus={autoFocusButton === 'cancel'}
buttonProps={cancelButtonProps}
>
{cancelText}
</ActionButton>
);
return (
<Dialog
prefixCls={prefixCls}
className={classString}
wrapClassName={classNames({ [`${contentPrefixCls}-centered`]: !!props.centered })}
onCancel={() => close({ triggerCancel: true })}
visible={visible}
title=""
transitionName={transitionName}
footer=""
maskTransitionName={maskTransitionName}
mask={mask}
maskClosable={maskClosable}
maskStyle={maskStyle}
style={style}
width={width}
zIndex={zIndex}
afterClose={afterClose}
keyboard={keyboard}
centered={centered}
getContainer={getContainer}
>
<div className={`${contentPrefixCls}-body-wrapper`}>
<div className={`${contentPrefixCls}-body`}>
{icon}
{props.title === undefined ? null : (
<span className={`${contentPrefixCls}-title`}>{props.title}</span>
)}
<div className={`${contentPrefixCls}-content`}>{props.content}</div>
</div>
<div className={`${contentPrefixCls}-btns`}>
{cancelButton}
<ActionButton
type={okType}
actionFn={onOk}
closeModal={close}
autoFocus={autoFocusButton === 'ok'}
buttonProps={okButtonProps}
>
{okText}
</ActionButton>
</div>
</div>
</Dialog>
);
};
export default ConfirmDialog;

View File

@ -4,6 +4,7 @@ import classNames from 'classnames';
import addEventListener from 'rc-util/lib/Dom/addEventListener';
import { CloseOutlined } from '@ant-design/icons';
import useModal from './useModal';
import { getConfirmLocale } from './locale';
import Button from '../button';
import { ButtonType, NativeButtonProps } from '../button/button';
@ -113,13 +114,6 @@ export interface ModalFuncProps {
maskTransitionName?: string;
}
export type ModalFunc = (
props: ModalFuncProps,
) => {
destroy: () => void;
update: (newConfig: ModalFuncProps) => void;
};
export interface ModalLocale {
okText: string;
cancelText: string;
@ -127,20 +121,10 @@ export interface ModalLocale {
}
export default class Modal extends React.Component<ModalProps, {}> {
static info: ModalFunc;
static success: ModalFunc;
static error: ModalFunc;
static warn: ModalFunc;
static warning: ModalFunc;
static confirm: ModalFunc;
static destroyAll: () => void;
static useModal = useModal;
static defaultProps = {
width: 520,
transitionName: 'zoom',

View File

@ -0,0 +1,54 @@
import React from 'react';
import { mount } from 'enzyme';
import Modal from '..';
import Button from '../../button';
jest.mock('rc-util/lib/Portal');
describe('Modal.hook', () => {
it('hooks support context', () => {
jest.useFakeTimers();
const Context = React.createContext('light');
let instance;
const Demo = () => {
const [modal, contextHolder] = Modal.useModal();
return (
<Context.Provider value="bamboo">
<Button
onClick={() => {
instance = modal.confirm({
content: (
<Context.Consumer>
{name => <div className="test-hook">{name}</div>}
</Context.Consumer>
),
});
}}
/>
{contextHolder}
</Context.Provider>
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
expect(wrapper.find('.test-hook').text()).toEqual('bamboo');
// Update instance
instance.update({
content: <div className="updated-content" />,
});
wrapper.update();
expect(wrapper.find('.updated-content')).toHaveLength(1);
// Destroy
instance.destroy();
jest.runAllTimers();
wrapper.update();
expect(wrapper.find('Modal')).toHaveLength(0);
jest.useRealTimers();
});
});

View File

@ -1,125 +1,31 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import classNames from 'classnames';
import Dialog, { ModalFuncProps, destroyFns } from './Modal';
import ActionButton from './ActionButton';
import {
InfoCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { getConfirmLocale } from './locale';
import warning from '../_util/warning';
import { ModalFuncProps, destroyFns } from './Modal';
import ConfirmDialog from './ConfirmDialog';
interface ConfirmDialogProps extends ModalFuncProps {
afterClose?: () => void;
close: (...args: any[]) => void;
autoFocusButton?: null | 'ok' | 'cancel';
}
const IS_REACT_16 = !!ReactDOM.createPortal;
const ConfirmDialog = (props: ConfirmDialogProps) => {
const {
icon,
onCancel,
onOk,
close,
zIndex,
afterClose,
visible,
keyboard,
centered,
getContainer,
maskStyle,
okButtonProps,
cancelButtonProps,
} = props;
warning(
!(typeof icon === 'string' && icon.length > 2),
'Modal',
`\`icon\` is using ReactNode instead of string naming in v4. Please check \`${icon}\` at https://ant.design/components/icon`,
);
// 支持传入{ icon: null }来隐藏`Modal.confirm`默认的Icon
const okType = props.okType || 'primary';
const prefixCls = props.prefixCls || 'ant-modal';
const contentPrefixCls = `${prefixCls}-confirm`;
// 默认为 true保持向下兼容
const okCancel = 'okCancel' in props ? props.okCancel! : true;
const width = props.width || 416;
const style = props.style || {};
const mask = props.mask === undefined ? true : props.mask;
// 默认为 false保持旧版默认行为
const maskClosable = props.maskClosable === undefined ? false : props.maskClosable;
const runtimeLocale = getConfirmLocale();
const okText = props.okText || (okCancel ? runtimeLocale.okText : runtimeLocale.justOkText);
const cancelText = props.cancelText || runtimeLocale.cancelText;
const autoFocusButton = props.autoFocusButton === null ? false : props.autoFocusButton || 'ok';
const transitionName = props.transitionName || 'zoom';
const maskTransitionName = props.maskTransitionName || 'fade';
const classString = classNames(
contentPrefixCls,
`${contentPrefixCls}-${props.type}`,
props.className,
);
const cancelButton = okCancel && (
<ActionButton
actionFn={onCancel}
closeModal={close}
autoFocus={autoFocusButton === 'cancel'}
buttonProps={cancelButtonProps}
>
{cancelText}
</ActionButton>
);
return (
<Dialog
prefixCls={prefixCls}
className={classString}
wrapClassName={classNames({ [`${contentPrefixCls}-centered`]: !!props.centered })}
onCancel={() => close({ triggerCancel: true })}
visible={visible}
title=""
transitionName={transitionName}
footer=""
maskTransitionName={maskTransitionName}
mask={mask}
maskClosable={maskClosable}
maskStyle={maskStyle}
style={style}
width={width}
zIndex={zIndex}
afterClose={afterClose}
keyboard={keyboard}
centered={centered}
getContainer={getContainer}
>
<div className={`${contentPrefixCls}-body-wrapper`}>
<div className={`${contentPrefixCls}-body`}>
{icon}
{props.title === undefined ? null : (
<span className={`${contentPrefixCls}-title`}>{props.title}</span>
)}
<div className={`${contentPrefixCls}-content`}>{props.content}</div>
</div>
<div className={`${contentPrefixCls}-btns`}>
{cancelButton}
<ActionButton
type={okType}
actionFn={onOk}
closeModal={close}
autoFocus={autoFocusButton === 'ok'}
buttonProps={okButtonProps}
>
{okText}
</ActionButton>
</div>
</div>
</Dialog>
);
export type ModalFunc = (
props: ModalFuncProps,
) => {
destroy: () => void;
update: (newConfig: ModalFuncProps) => void;
};
export interface ModalStaticFunctions {
info: ModalFunc;
success: ModalFunc;
error: ModalFunc;
warn: ModalFunc;
warning: ModalFunc;
confirm: ModalFunc;
}
export default function confirm(config: ModalFuncProps) {
const div = document.createElement('div');
document.body.appendChild(div);
@ -145,8 +51,16 @@ export default function confirm(config: ModalFuncProps) {
}
}
function render(props: any) {
ReactDOM.render(<ConfirmDialog {...props} />, div);
function render({ okText, cancelText, ...props }: any) {
const runtimeLocale = getConfirmLocale();
ReactDOM.render(
<ConfirmDialog
{...props}
okText={okText || (props.okCancel ? runtimeLocale.okText : runtimeLocale.justOkText)}
cancelText={cancelText || runtimeLocale.cancelText}
/>,
div,
);
}
function close(...args: any[]) {
@ -155,11 +69,7 @@ export default function confirm(config: ModalFuncProps) {
visible: false,
afterClose: destroy.bind(this, ...args),
};
if (IS_REACT_16) {
render(currentConfig);
} else {
destroy(...args);
}
render(currentConfig);
}
function update(newConfig: ModalFuncProps) {
@ -179,3 +89,47 @@ export default function confirm(config: ModalFuncProps) {
update,
};
}
export function withWarn(props: ModalFuncProps): ModalFuncProps {
return {
type: 'warning',
icon: <ExclamationCircleOutlined />,
okCancel: false,
...props,
};
}
export function withInfo(props: ModalFuncProps): ModalFuncProps {
return {
type: 'info',
icon: <InfoCircleOutlined />,
okCancel: false,
...props,
};
}
export function withSuccess(props: ModalFuncProps): ModalFuncProps {
return {
type: 'success',
icon: <CheckCircleOutlined />,
okCancel: false,
...props,
};
}
export function withError(props: ModalFuncProps): ModalFuncProps {
return {
type: 'error',
icon: <CloseCircleOutlined />,
okCancel: false,
...props,
};
}
export function withConfirm(props: ModalFuncProps): ModalFuncProps {
return {
type: 'confirm',
okCancel: true,
...props,
};
}

View File

@ -0,0 +1,77 @@
---
order: 12
title:
zh-CN: 使用 hooks 获得上下文
en-US: Use hooks to get context
---
## zh-CN
通过 `Modal.useModal` 创建支持读取 context 的 `contextHolder`
## en-US
Use `Modal.useModal` to get `contextHolder` with context accessible issue.
```jsx
import { Modal, Button } from 'antd';
const ReachableContext = React.createContext();
const UnreachableContext = React.createContext();
const config = {
title: 'Use Hook!',
content: (
<div>
<ReachableContext.Consumer>{name => `Reachable: ${name}!`}</ReachableContext.Consumer>
<br />
<UnreachableContext.Consumer>{name => `Unreachable: ${name}!`}</UnreachableContext.Consumer>
</div>
),
};
const App = () => {
const [modal, contextHolder] = Modal.useModal();
return (
<ReachableContext.Provider value="Light">
<Button
onClick={() => {
modal.confirm(config);
}}
>
Confirm
</Button>
<Button
onClick={() => {
modal.warning(config);
}}
>
Warning
</Button>
<Button
onClick={() => {
modal.info(config);
}}
>
Info
</Button>
<Button
onClick={() => {
modal.error(config);
}}
>
Error
</Button>
{/* `contextHolder` should always under the context you want to access */}
{contextHolder}
{/* Can not access this context since `contextHolder` is not in it */}
<UnreachableContext.Provider value="Bamboo" />
</ReachableContext.Provider>
);
};
ReactDOM.render(<App />, mountNode);
```

View File

@ -109,3 +109,19 @@ browserHistory.listen(() => {
Modal.destroyAll();
});
```
### Modal.useModal()
When you need using Context, you can use `contextHolder` which created by `Modal.useModal` to insert into children. Modal created by hooks will get all the context where `contextHolder` are. Created `modal` has the same creating function with `Modal.method`](<#Modal.method()>).
```jsx
const [modal, contextHolder] = Modal.useModal();
React.useEffect(() => {
modal.confirm({
// ...
});
}, []);
return <div>{contextHolder}</div>;
```

View File

@ -1,55 +1,33 @@
import * as React from 'react';
import {
InfoCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import Modal, { ModalFuncProps, destroyFns } from './Modal';
import confirm from './confirm';
import OriginModal, { ModalFuncProps, destroyFns } from './Modal';
import confirm, {
withWarn,
withInfo,
withSuccess,
withError,
withConfirm,
ModalStaticFunctions,
} from './confirm';
export { ActionButtonProps } from './ActionButton';
export { ModalProps, ModalFuncProps } from './Modal';
function modalWarn(props: ModalFuncProps) {
const config = {
type: 'warning',
icon: <ExclamationCircleOutlined />,
okCancel: false,
...props,
};
return confirm(config);
return confirm(withWarn(props));
}
type Modal = typeof OriginModal & ModalStaticFunctions;
const Modal = OriginModal as Modal;
Modal.info = function infoFn(props: ModalFuncProps) {
const config = {
type: 'info',
icon: <InfoCircleOutlined />,
okCancel: false,
...props,
};
return confirm(config);
return confirm(withInfo(props));
};
Modal.success = function successFn(props: ModalFuncProps) {
const config = {
type: 'success',
icon: <CheckCircleOutlined />,
okCancel: false,
...props,
};
return confirm(config);
return confirm(withSuccess(props));
};
Modal.error = function errorFn(props: ModalFuncProps) {
const config = {
type: 'error',
icon: <CloseCircleOutlined />,
okCancel: false,
...props,
};
return confirm(config);
return confirm(withError(props));
};
Modal.warning = modalWarn;
@ -57,12 +35,7 @@ Modal.warning = modalWarn;
Modal.warn = modalWarn;
Modal.confirm = function confirmFn(props: ModalFuncProps) {
const config = {
type: 'confirm',
okCancel: true,
...props,
};
return confirm(config);
return confirm(withConfirm(props));
};
Modal.destroyAll = function destroyAllFn() {

View File

@ -111,3 +111,19 @@ browserHistory.listen(() => {
Modal.destroyAll();
});
```
### Modal.useModal()
当你需要使用 Context 时,可以通过 `Modal.useModal` 创建一个 `contextHolder` 插入子节点中。通过 hooks 创建的临时 Modal 将会得到 `contextHolder` 所在位置的所有上下文。创建的 `modal` 对象拥有与 [`Modal.method`](<#Modal.method()>) 相同的创建通知方法。
```jsx
const [modal, contextHolder] = Modal.useModal();
React.useEffect(() => {
modal.confirm({
// ...
});
}, []);
return <div>{contextHolder}</div>;
```

View File

@ -0,0 +1,63 @@
import * as React from 'react';
import { ModalFuncProps } from '../Modal';
import ConfirmDialog from '../ConfirmDialog';
import defaultLocale from '../../locale/default';
import LocaleReceiver from '../../locale-provider/LocaleReceiver';
export interface HookModalProps {
afterClose: () => void;
config: ModalFuncProps;
}
export interface HookModalRef {
destroy: () => void;
update: (config: ModalFuncProps) => void;
}
interface ModalLocale {
okText: string;
cancelText: string;
justOkText: string;
}
const HookModal: React.RefForwardingComponent<HookModalRef, HookModalProps> = (
{ afterClose, config },
ref,
) => {
const [visible, setVisible] = React.useState(true);
const [innerConfig, setInnerConfig] = React.useState(config);
function close() {
setVisible(false);
}
React.useImperativeHandle(ref, () => ({
destroy: close,
update: (newConfig: ModalFuncProps) => {
setInnerConfig(originConfig => ({
...originConfig,
...newConfig,
}));
},
}));
return (
<LocaleReceiver componentName="Modal" defaultLocale={defaultLocale.Modal}>
{(modalLocale: ModalLocale) => (
<ConfirmDialog
{...innerConfig}
close={close}
visible={visible}
afterClose={afterClose}
okText={
innerConfig.okText ||
(innerConfig.okCancel ? modalLocale.okText : modalLocale.justOkText)
}
cancelText={innerConfig.cancelText || modalLocale.cancelText}
/>
)}
</LocaleReceiver>
);
};
export default React.forwardRef(HookModal);

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import { ModalFuncProps } from '../Modal';
import usePatchElement from '../../_util/usePatchElement';
import HookModal, { HookModalRef } from './HookModal';
import {
withConfirm,
ModalStaticFunctions,
withInfo,
withSuccess,
withError,
withWarn,
} from '../confirm';
let uuid = 0;
export default function useModal(): [Omit<ModalStaticFunctions, 'warn'>, React.ReactElement] {
const [elements, patchElement] = usePatchElement();
function getConfirmFunc(withFunc: (config: ModalFuncProps) => ModalFuncProps) {
return function hookConfirm(config: ModalFuncProps) {
uuid += 1;
const modalRef = React.createRef<HookModalRef>();
let closeFunc: Function;
const modal = (
<HookModal
key={`modal-${uuid}`}
config={withFunc(config)}
ref={modalRef}
afterClose={() => {
closeFunc();
}}
/>
);
closeFunc = patchElement(modal);
return {
destroy: () => {
if (modalRef.current) {
modalRef.current.destroy();
}
},
update: (newConfig: ModalFuncProps) => {
if (modalRef.current) {
modalRef.current.update(newConfig);
}
},
};
};
}
return [
{
info: getConfirmFunc(withInfo),
success: getConfirmFunc(withSuccess),
error: getConfirmFunc(withError),
warning: getConfirmFunc(withWarn),
confirm: getConfirmFunc(withConfirm),
},
<>{elements}</>,
];
}