From fb1d2fc737fc9455d8ed8bf20eaa59f932b2369b Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Wed, 5 Jan 2022 22:03:53 +0800 Subject: [PATCH] refactor: modal --- components/modal/ActionButton.tsx | 168 ++++++----- components/modal/ConfirmDialog.tsx | 48 ++- components/modal/Modal.tsx | 275 +++++++++--------- .../__snapshots__/Modal.test.js.snap | 70 +++-- .../__tests__/__snapshots__/demo.test.js.snap | 8 + components/modal/__tests__/confirm.test.js | 14 +- components/modal/confirm.tsx | 116 ++++++-- components/modal/demo/modal-render.vue | 99 +++++++ components/modal/index.tsx | 70 ++--- components/vc-dialog/Content.tsx | 32 +- components/vc-dialog/Dialog.tsx | 3 +- components/vc-dialog/Mask.tsx | 4 +- package.json | 1 + 13 files changed, 541 insertions(+), 367 deletions(-) create mode 100644 components/modal/demo/modal-render.vue diff --git a/components/modal/ActionButton.tsx b/components/modal/ActionButton.tsx index f3aa99f7f..cc3766368 100644 --- a/components/modal/ActionButton.tsx +++ b/components/modal/ActionButton.tsx @@ -1,89 +1,113 @@ import type { ExtractPropTypes, PropType } from 'vue'; -import { defineComponent } from 'vue'; -import PropTypes from '../_util/vue-types'; +import { onMounted, ref, defineComponent, onBeforeUnmount } from 'vue'; import Button from '../button'; -import BaseMixin from '../_util/BaseMixin'; +import type { ButtonProps } from '../button'; import type { LegacyButtonType } from '../button/buttonTypes'; import { convertLegacyProps } from '../button/buttonTypes'; -import { getSlot, findDOMNode } from '../_util/props-util'; -const ActionButtonProps = { +const actionButtonProps = { type: { type: String as PropType, }, - actionFn: PropTypes.func, - closeModal: PropTypes.func, - autofocus: PropTypes.looseBool, - buttonProps: PropTypes.object, + actionFn: Function as PropType<(...args: any[]) => any | PromiseLike>, + close: Function, + autofocus: Boolean, + prefixCls: String, + buttonProps: Object as PropType, + emitEvent: Boolean, + quitOnNullishReturnValue: Boolean, }; -export type IActionButtonProps = ExtractPropTypes; +export type ActionButtonProps = ExtractPropTypes; + +function isThenable(thing?: PromiseLike): boolean { + return !!(thing && !!thing.then); +} export default defineComponent({ - mixins: [BaseMixin], - props: ActionButtonProps, - setup() { - return { - timeoutId: undefined, - }; - }, - data() { - return { - loading: false, - }; - }, - mounted() { - if (this.autofocus) { - this.timeoutId = setTimeout(() => findDOMNode(this).focus()); - } - }, - beforeUnmount() { - clearTimeout(this.timeoutId); - }, - methods: { - onClick() { - const { actionFn, closeModal } = this; - if (actionFn) { - let ret: any; - if (actionFn.length) { - ret = actionFn(closeModal); - } else { - ret = actionFn(); - if (!ret) { - closeModal(); - } - } - if (ret && ret.then) { - this.setState({ loading: true }); - ret.then( - (...args: any[]) => { - // It's unnecessary to set loading=false, for the Modal will be unmounted after close. - // this.setState({ loading: false }); - closeModal(...args); - }, - (e: Event) => { - // Emit error when catch promise reject - // eslint-disable-next-line no-console - console.error(e); - // See: https://github.com/ant-design/ant-design/issues/6183 - this.setState({ loading: false }); - }, - ); - } - } else { - closeModal(); + name: 'ActionButton', + props: actionButtonProps, + setup(props, { slots }) { + const clickedRef = ref(false); + const buttonRef = ref(); + const loading = ref(false); + let timeoutId: any; + onMounted(() => { + if (props.autofocus) { + timeoutId = setTimeout(() => buttonRef.value.$el?.focus()); } - }, - }, + }); + onBeforeUnmount(() => { + clearTimeout(timeoutId); + }); - render() { - const { type, loading, buttonProps } = this; - const props = { - ...convertLegacyProps(type), - onClick: this.onClick, - loading, - ...buttonProps, + const handlePromiseOnOk = (returnValueOfOnOk?: PromiseLike) => { + const { close } = props; + if (!isThenable(returnValueOfOnOk)) { + return; + } + loading.value = true; + returnValueOfOnOk!.then( + (...args: any[]) => { + loading.value = false; + close(...args); + clickedRef.value = false; + }, + (e: Error) => { + // Emit error when catch promise reject + // eslint-disable-next-line no-console + console.error(e); + // See: https://github.com/ant-design/ant-design/issues/6183 + loading.value = false; + clickedRef.value = false; + }, + ); + }; + + const onClick = (e: MouseEvent) => { + const { actionFn, close = () => {} } = props; + if (clickedRef.value) { + return; + } + clickedRef.value = true; + if (!actionFn) { + close(); + return; + } + let returnValueOfOnOk; + if (props.emitEvent) { + returnValueOfOnOk = actionFn(e); + if (props.quitOnNullishReturnValue && !isThenable(returnValueOfOnOk)) { + clickedRef.value = false; + close(e); + return; + } + } else if (actionFn.length) { + returnValueOfOnOk = actionFn(close); + // https://github.com/ant-design/ant-design/issues/23358 + clickedRef.value = false; + } else { + returnValueOfOnOk = actionFn(); + if (!returnValueOfOnOk) { + close(); + return; + } + } + handlePromiseOnOk(returnValueOfOnOk); + }; + return () => { + const { type, prefixCls, buttonProps } = props; + return ( + + ); }; - return ; }, }); diff --git a/components/modal/ConfirmDialog.tsx b/components/modal/ConfirmDialog.tsx index b57efd09d..97226393b 100644 --- a/components/modal/ConfirmDialog.tsx +++ b/components/modal/ConfirmDialog.tsx @@ -4,14 +4,17 @@ import Dialog from './Modal'; import ActionButton from './ActionButton'; import { defineComponent } from 'vue'; import { useLocaleReceiver } from '../locale-provider/LocaleReceiver'; +import { getTransitionName } from '../_util/transition'; interface ConfirmDialogProps extends ModalFuncProps { afterClose?: () => void; close?: (...args: any[]) => void; autoFocusButton?: null | 'ok' | 'cancel'; + rootPrefixCls: string; + iconPrefixCls?: string; } -function renderSomeContent(_name, someContent) { +function renderSomeContent(someContent: any) { if (typeof someContent === 'function') { return someContent(); } @@ -50,6 +53,12 @@ export default defineComponent({ 'type', 'title', 'content', + 'direction', + 'rootPrefixCls', + 'bodyStyle', + 'closeIcon', + 'modalRender', + 'focusTriggerAfterClose', ] as any, setup(props, { attrs }) { const [locale] = useLocaleReceiver('Modal'); @@ -73,22 +82,24 @@ export default defineComponent({ width = 416, mask = true, maskClosable = false, - maskTransitionName = 'fade', - transitionName = 'zoom', type, title, content, - // closable = false, + direction, + closeIcon, + modalRender, + focusTriggerAfterClose, + rootPrefixCls, + bodyStyle, } = props; const okType = props.okType || 'primary'; const prefixCls = props.prefixCls || 'ant-modal'; const contentPrefixCls = `${prefixCls}-confirm`; const style = attrs.style || {}; const okText = - renderSomeContent('okText', props.okText) || + renderSomeContent(props.okText) || (okCancel ? locale.value.okText : locale.value.justOkText); - const cancelText = - renderSomeContent('cancelText', props.cancelText) || locale.value.cancelText; + const cancelText = renderSomeContent(props.cancelText) || locale.value.cancelText; const autoFocusButton = props.autoFocusButton === null ? false : props.autoFocusButton || 'ok'; @@ -96,15 +107,17 @@ export default defineComponent({ contentPrefixCls, `${contentPrefixCls}-${type}`, `${prefixCls}-${type}`, + { [`${contentPrefixCls}-rtl`]: direction === 'rtl' }, attrs.class, ); const cancelButton = okCancel && ( {cancelText} @@ -118,13 +131,14 @@ export default defineComponent({ onCancel={e => close({ triggerCancel: true }, e)} visible={visible} title="" - transitionName={transitionName} footer="" - maskTransitionName={maskTransitionName} + transitionName={getTransitionName(rootPrefixCls, 'zoom', props.transitionName)} + maskTransitionName={getTransitionName(rootPrefixCls, 'fade', props.maskTransitionName)} mask={mask} maskClosable={maskClosable} maskStyle={maskStyle} style={style} + bodyStyle={bodyStyle} width={width} zIndex={zIndex} afterClose={afterClose} @@ -132,25 +146,27 @@ export default defineComponent({ centered={centered} getContainer={getContainer} closable={closable} + closeIcon={closeIcon} + modalRender={modalRender} + focusTriggerAfterClose={focusTriggerAfterClose} >
- {renderSomeContent('icon', icon)} + {renderSomeContent(icon)} {title === undefined ? null : ( - {renderSomeContent('title', title)} + {renderSomeContent(title)} )} -
- {renderSomeContent('content', content)} -
+
{renderSomeContent(content)}
{cancelButton} {okText} diff --git a/components/modal/Modal.tsx b/components/modal/Modal.tsx index 9c6653f76..4183bc969 100644 --- a/components/modal/Modal.tsx +++ b/components/modal/Modal.tsx @@ -1,5 +1,5 @@ import type { ExtractPropTypes, CSSProperties, PropType } from 'vue'; -import { defineComponent, inject } from 'vue'; +import { defineComponent } from 'vue'; import classNames from '../_util/classNames'; import Dialog from '../vc-dialog'; import PropTypes from '../_util/vue-types'; @@ -7,12 +7,14 @@ import addEventListener from '../vc-util/Dom/addEventListener'; import CloseOutlined from '@ant-design/icons-vue/CloseOutlined'; import Button from '../button'; import type { ButtonProps as ButtonPropsType, LegacyButtonType } from '../button/buttonTypes'; -import buttonTypes, { convertLegacyProps } from '../button/buttonTypes'; +import { convertLegacyProps } from '../button/buttonTypes'; import { useLocaleReceiver } from '../locale-provider/LocaleReceiver'; -import { getComponent, getSlot } from '../_util/props-util'; import initDefaultProps from '../_util/props-util/initDefaultProps'; -import { defaultConfigProvider } from '../config-provider'; +import type { Direction } from '../config-provider'; import type { VueNode } from '../_util/type'; +import { canUseDocElement } from '../_util/styleChecker'; +import useConfigInject from '../_util/hooks/useConfigInject'; +import { getTransitionName } from '../_util/transition'; let mousePosition: { x: number; y: number } | null = null; // ref: https://github.com/ant-design/ant-design/issues/15795 @@ -28,68 +30,51 @@ const getClickPosition = (e: MouseEvent) => { }; // 只有点击事件支持从鼠标位置动画展开 -if (typeof window !== 'undefined' && window.document && window.document.documentElement) { +if (canUseDocElement()) { addEventListener(document.documentElement, 'click', getClickPosition, true); } -function noop() {} - -const modalProps = { - prefixCls: PropTypes.string, - /** 对话框是否可见*/ - visible: PropTypes.looseBool, - /** 确定按钮 loading*/ - confirmLoading: PropTypes.looseBool, - /** 标题*/ +const modalProps = () => ({ + prefixCls: String, + visible: { type: Boolean, default: undefined }, + confirmLoading: { type: Boolean, default: undefined }, title: PropTypes.any, - /** 是否显示右上角的关闭按钮*/ - closable: PropTypes.looseBool, + closable: { type: Boolean, default: undefined }, closeIcon: PropTypes.any, - /** 点击确定回调*/ - onOk: { - type: Function as PropType<(e: MouseEvent) => void>, - }, - /** 点击模态框右上角叉、取消按钮、Props.maskClosable 值为 true 时的遮罩层或键盘按下 Esc 时的回调*/ - onCancel: { - type: Function as PropType<(e: MouseEvent) => void>, - }, - afterClose: PropTypes.func.def(noop), - /** 垂直居中 */ - centered: PropTypes.looseBool, - /** 宽度*/ - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** 底部内容*/ + onOk: Function as PropType<(e: MouseEvent) => void>, + onCancel: Function as PropType<(e: MouseEvent) => void>, + 'onUpdate:visible': Function as PropType<(visible: boolean) => void>, + onChange: Function as PropType<(visible: boolean) => void>, + afterClose: Function as PropType<() => void>, + centered: { type: Boolean, default: undefined }, + width: [String, Number], footer: PropTypes.any, - /** 确认按钮文字*/ okText: PropTypes.any, - /** 确认按钮类型*/ - okType: { - type: String as PropType, - }, - /** 取消按钮文字*/ + okType: String as PropType, cancelText: PropTypes.any, icon: PropTypes.any, - /** 点击蒙层是否允许关闭*/ - maskClosable: PropTypes.looseBool, - /** 强制渲染 Modal*/ - forceRender: PropTypes.looseBool, - okButtonProps: PropTypes.shape(buttonTypes).loose, - cancelButtonProps: PropTypes.shape(buttonTypes).loose, - destroyOnClose: PropTypes.looseBool, - wrapClassName: PropTypes.string, - maskTransitionName: PropTypes.string, - transitionName: PropTypes.string, - getContainer: PropTypes.any, - zIndex: PropTypes.number, - bodyStyle: PropTypes.style, - maskStyle: PropTypes.style, - mask: PropTypes.looseBool, - keyboard: PropTypes.looseBool, - wrapProps: PropTypes.object, - focusTriggerAfterClose: PropTypes.looseBool, -}; + maskClosable: { type: Boolean, default: undefined }, + forceRender: { type: Boolean, default: undefined }, + okButtonProps: Object as PropType, + cancelButtonProps: Object as PropType, + destroyOnClose: { type: Boolean, default: undefined }, + wrapClassName: String, + maskTransitionName: String, + transitionName: String, + getContainer: [String, Function, Boolean, Object] as PropType< + string | HTMLElement | getContainerFunc | false + >, + zIndex: Number, + bodyStyle: Object as PropType, + maskStyle: Object as PropType, + mask: { type: Boolean, default: undefined }, + keyboard: { type: Boolean, default: undefined }, + wrapProps: Object, + focusTriggerAfterClose: { type: Boolean, default: undefined }, + modalRender: Function as PropType<(arg: { originVNode: VueNode }) => VueNode>, +}); -export type ModalProps = ExtractPropTypes; +export type ModalProps = Partial>>; export interface ModalFuncProps { prefixCls?: string; @@ -101,6 +86,7 @@ export interface ModalFuncProps { // TODO: find out exact types onOk?: (...args: any[]) => any; onCancel?: (...args: any[]) => any; + afterClose?: () => void; okButtonProps?: ButtonPropsType; cancelButtonProps?: ButtonPropsType; centered?: boolean; @@ -117,12 +103,17 @@ export interface ModalFuncProps { okCancel?: boolean; style?: CSSProperties | string; maskStyle?: CSSProperties; - type?: string; + type?: 'info' | 'success' | 'error' | 'warn' | 'warning' | 'confirm'; keyboard?: boolean; - getContainer?: getContainerFunc | boolean | string; + getContainer?: string | HTMLElement | getContainerFunc | false; autoFocusButton?: null | 'ok' | 'cancel'; transitionName?: string; maskTransitionName?: string; + direction?: Direction; + bodyStyle?: CSSProperties; + closeIcon?: string | (() => VueNode) | VueNode; + modalRender?: (arg: { originVNode: VueNode }) => VueNode; + focusTriggerAfterClose?: boolean; /** @deprecated please use `appContext` instead */ parentContext?: any; @@ -147,7 +138,7 @@ export const destroyFns = []; export default defineComponent({ name: 'AModal', inheritAttrs: false, - props: initDefaultProps(modalProps, { + props: initDefaultProps(modalProps(), { width: 520, transitionName: 'zoom', maskTransitionName: 'fade', @@ -155,90 +146,92 @@ export default defineComponent({ visible: false, okType: 'primary', }), - emits: ['update:visible', 'cancel', 'change', 'ok'], - setup() { + setup(props, { emit, slots, attrs }) { const [locale] = useLocaleReceiver('Modal'); - return { - locale, - configProvider: inject('configProvider', defaultConfigProvider), - }; - }, - data() { - return { - sVisible: !!this.visible, - }; - }, - watch: { - visible(val) { - this.sVisible = val; - }, - }, - methods: { - handleCancel(e: MouseEvent) { - this.$emit('update:visible', false); - this.$emit('cancel', e); - this.$emit('change', false); - }, - - handleOk(e: MouseEvent) { - this.$emit('ok', e); - }, - renderFooter() { - const { okType, confirmLoading, locale } = this; - const cancelBtnProps = { onClick: this.handleCancel, ...(this.cancelButtonProps || {}) }; - const okBtnProps = { - onClick: this.handleOk, - ...convertLegacyProps(okType), - loading: confirmLoading, - ...(this.okButtonProps || {}), - }; - - return ( -
- - -
- ); - }, - }, - - render() { - const { - prefixCls: customizePrefixCls, - sVisible: visible, - wrapClassName, - centered, - getContainer, - $attrs, - } = this; - const children = getSlot(this); - const { getPrefixCls, getPopupContainer: getContextPopupContainer } = this.configProvider; - const prefixCls = getPrefixCls('modal', customizePrefixCls); - - const defaultFooter = this.renderFooter(); - const closeIcon = getComponent(this, 'closeIcon'); - const closeIconToRender = ( - - {closeIcon || } - + const { prefixCls, rootPrefixCls, direction, getPopupContainer } = useConfigInject( + 'modal', + props, ); - const footer = getComponent(this, 'footer'); - const title = getComponent(this, 'title'); - const dialogProps = { - ...this.$props, - ...$attrs, - getContainer: getContainer === undefined ? getContextPopupContainer : getContainer, - prefixCls, - wrapClassName: classNames({ [`${prefixCls}-centered`]: !!centered }, wrapClassName), - title, - footer: footer === undefined ? defaultFooter : footer, - visible, - mousePosition, - closeIcon: closeIconToRender, - onClose: this.handleCancel, + + const handleCancel = (e: MouseEvent) => { + emit('update:visible', false); + emit('cancel', e); + emit('change', false); + }; + + const handleOk = (e: MouseEvent) => { + emit('ok', e); + }; + + const renderFooter = () => { + const { + okText = slots.okText?.(), + okType, + cancelText = slots.cancelText?.(), + confirmLoading, + } = props; + return ( + <> + + + + ); + }; + return () => { + const { + prefixCls: customizePrefixCls, + visible, + wrapClassName, + centered, + getContainer, + closeIcon = slots.closeIcon?.(), + focusTriggerAfterClose = true, + ...restProps + } = props; + + const wrapClassNameExtended = classNames(wrapClassName, { + [`${prefixCls.value}-centered`]: !!centered, + [`${prefixCls.value}-wrap-rtl`]: direction.value === 'rtl', + }); + return ( + { + return ( + + {closeIcon || } + + ); + }, + }} + > + ); }; - return {children}; }, }); diff --git a/components/modal/__tests__/__snapshots__/Modal.test.js.snap b/components/modal/__tests__/__snapshots__/Modal.test.js.snap index c8d0991ac..5289c9c0f 100644 --- a/components/modal/__tests__/__snapshots__/Modal.test.js.snap +++ b/components/modal/__tests__/__snapshots__/Modal.test.js.snap @@ -5,21 +5,19 @@ exports[`Modal render correctly 1`] = `
-