feat(modal): add useModal (#6517)
* feat(modal): add useModal hook * feat(modal): add HookModal demo * test(modal): update HookModal demo snap * feat(modal): update modal docs * chore(modal): update modal typepull/6527/head
							parent
							
								
									0df6abe3a7
								
							
						
					
					
						commit
						69640c0af8
					
				|  | @ -149,8 +149,6 @@ export interface ModalLocale { | |||
|   justOkText: string; | ||||
| } | ||||
| 
 | ||||
| export const destroyFns = []; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   compatConfig: { MODE: 3 }, | ||||
|   name: 'AModal', | ||||
|  | @ -167,6 +165,7 @@ export default defineComponent({ | |||
|       props, | ||||
|     ); | ||||
|     const [wrapSSR, hashId] = useStyle(prefixCls); | ||||
| 
 | ||||
|     warning( | ||||
|       props.visible === undefined, | ||||
|       'Modal', | ||||
|  |  | |||
|  | @ -1,5 +1,17 @@ | |||
| // Jest Snapshot v1, https://goo.gl/fbAQLP | ||||
| 
 | ||||
| exports[`renders ./components/modal/demo/HookModal.vue correctly 1`] = ` | ||||
| <div><button class="ant-btn ant-btn-default" type="button"> | ||||
|     <!----><span>Confirm</span> | ||||
|   </button><button class="ant-btn ant-btn-default" type="button"> | ||||
|     <!----><span>With promise</span> | ||||
|   </button><button class="ant-btn ant-btn-dashed" type="button"> | ||||
|     <!----><span>Delete</span> | ||||
|   </button><button class="ant-btn ant-btn-dashed" type="button"> | ||||
|     <!----><span>With extra props</span> | ||||
|   </button></div> | ||||
| `; | ||||
| 
 | ||||
| exports[`renders ./components/modal/demo/async.vue correctly 1`] = ` | ||||
| <div><button class="ant-btn ant-btn-primary" type="button"> | ||||
|     <!----><span>Open Modal with async logic</span> | ||||
|  |  | |||
|  | @ -1,13 +1,14 @@ | |||
| import { createVNode, render as vueRender } from 'vue'; | ||||
| import ConfirmDialog from './ConfirmDialog'; | ||||
| import type { ModalFuncProps } from './Modal'; | ||||
| import { destroyFns } from './Modal'; | ||||
| import ConfigProvider, { globalConfigForApi } from '../config-provider'; | ||||
| import omit from '../_util/omit'; | ||||
| 
 | ||||
| import { getConfirmLocale } from './locale'; | ||||
| import destroyFns from './destroyFns'; | ||||
| 
 | ||||
| type ConfigUpdate = ModalFuncProps | ((prevConfig: ModalFuncProps) => ModalFuncProps); | ||||
| export type ModalStaticFunctions<T = ModalFunc> = Record<NonNullable<ModalFuncProps['type']>, T>; | ||||
| 
 | ||||
| export type ModalFunc = (props: ModalFuncProps) => { | ||||
|   destroy: () => void; | ||||
|  |  | |||
|  | @ -0,0 +1,101 @@ | |||
| <docs> | ||||
| --- | ||||
| order: 12 | ||||
| title: | ||||
|   zh-CN: 使用useModal获取上下文 | ||||
|   en-US: Use useModal to get context | ||||
| --- | ||||
| 
 | ||||
| ## zh-CN | ||||
| 
 | ||||
| 通过 `Modal.useModal` 创建支持读取 context 的 `contextHolder`。 | ||||
| 
 | ||||
| ## en-US | ||||
| 
 | ||||
| Use `Modal.useModal` to get `contextHolder` with context accessible issue. | ||||
| 
 | ||||
| </docs> | ||||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <a-button @click="showConfirm">Confirm</a-button> | ||||
|     <a-button @click="showPromiseConfirm">With promise</a-button> | ||||
|     <a-button type="dashed" @click="showDeleteConfirm">Delete</a-button> | ||||
|     <a-button type="dashed" @click="showPropsConfirm">With extra props</a-button> | ||||
|     <contextHolder /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { Modal } from 'ant-design-vue'; | ||||
| import { ExclamationCircleOutlined } from '@ant-design/icons-vue'; | ||||
| import { createVNode } from 'vue'; | ||||
| const [modal, contextHolder] = Modal.useModal(); | ||||
| const showConfirm = () => { | ||||
|   modal.confirm({ | ||||
|     title: 'Do you Want to delete these items?', | ||||
|     icon: createVNode(ExclamationCircleOutlined), | ||||
|     content: createVNode('div', { style: 'color:red;' }, 'Some descriptions'), | ||||
|     onOk() { | ||||
|       console.log('OK'); | ||||
|     }, | ||||
|     onCancel() { | ||||
|       console.log('Cancel'); | ||||
|     }, | ||||
|     class: 'test', | ||||
|   }); | ||||
| }; | ||||
| const showDeleteConfirm = () => { | ||||
|   modal.confirm({ | ||||
|     title: 'Are you sure delete this task?', | ||||
|     icon: createVNode(ExclamationCircleOutlined), | ||||
|     content: 'Some descriptions', | ||||
|     okText: 'Yes', | ||||
|     okType: 'danger', | ||||
|     cancelText: 'No', | ||||
|     onOk() { | ||||
|       console.log('OK'); | ||||
|     }, | ||||
|     onCancel() { | ||||
|       console.log('Cancel'); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| const showPropsConfirm = () => { | ||||
|   modal.confirm({ | ||||
|     title: 'Are you sure delete this task?', | ||||
|     icon: createVNode(ExclamationCircleOutlined), | ||||
|     content: 'Some descriptions', | ||||
|     okText: 'Yes', | ||||
|     okType: 'danger', | ||||
|     okButtonProps: { | ||||
|       disabled: true, | ||||
|     }, | ||||
|     cancelText: 'No', | ||||
|     onOk() { | ||||
|       console.log('OK'); | ||||
|     }, | ||||
|     onCancel() { | ||||
|       console.log('Cancel'); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| function showPromiseConfirm() { | ||||
|   modal.confirm({ | ||||
|     title: 'Do you want to delete these items?', | ||||
|     icon: createVNode(ExclamationCircleOutlined), | ||||
|     content: 'When clicked the OK button, this dialog will be closed after 1 second', | ||||
|     async onOk() { | ||||
|       try { | ||||
|         return await new Promise((resolve, reject) => { | ||||
|           setTimeout(Math.random() > 0.5 ? resolve : reject, 1000); | ||||
|         }); | ||||
|       } catch { | ||||
|         return console.log('Oops errors!'); | ||||
|       } | ||||
|     }, | ||||
|     onCancel() {}, | ||||
|   }); | ||||
| } | ||||
| </script> | ||||
|  | @ -5,6 +5,7 @@ | |||
|     <custom-footer /> | ||||
|     <confirm /> | ||||
|     <info /> | ||||
|     <HookModal /> | ||||
|     <locale /> | ||||
|     <manual /> | ||||
|     <position /> | ||||
|  | @ -31,6 +32,7 @@ import Width from './width.vue'; | |||
| import Fullscreen from './fullscreen.vue'; | ||||
| import ButtonProps from './button-props.vue'; | ||||
| import modalRenderVue from './modal-render.vue'; | ||||
| import HookModal from './HookModal.vue'; | ||||
| import CN from '../index.zh-CN.md'; | ||||
| import US from '../index.en-US.md'; | ||||
| import { defineComponent } from 'vue'; | ||||
|  | @ -52,6 +54,7 @@ export default defineComponent({ | |||
|     ButtonProps, | ||||
|     Fullscreen, | ||||
|     modalRenderVue, | ||||
|     HookModal, | ||||
|   }, | ||||
|   setup() { | ||||
|     return {}; | ||||
|  |  | |||
|  | @ -0,0 +1,2 @@ | |||
| const destroyFns: Array<Function> = []; | ||||
| export default destroyFns; | ||||
|  | @ -116,21 +116,44 @@ router.beforeEach((to, from, next) => { | |||
| }) | ||||
| ``` | ||||
| 
 | ||||
| ### 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`. | ||||
| 
 | ||||
| ```html | ||||
| <template> | ||||
|   <contextHolder /> | ||||
|   <!-- <component :is='contextHolder'/> --> | ||||
| </template> | ||||
| <script setup> | ||||
|   import { Modal } from 'ant-design-vue'; | ||||
|   const [modal, contextHolder] = Modal.useModal(); | ||||
| 
 | ||||
|   modal.confirm({ | ||||
|     // ... | ||||
|   }); | ||||
| </script> | ||||
| ``` | ||||
| 
 | ||||
| ## FAQ | ||||
| 
 | ||||
| ### Why can't the Modal method obtain global registered components, context, vuex, etc. and ConfigProvider `locale/prefixCls/theme` configuration, and can't update data responsively? | ||||
| 
 | ||||
| Call the Modal method directly, and the component will dynamically create a new Vue entity through `Vue.render`. Its context is not the same as the context where the current code is located, so the context information cannot be obtained. | ||||
| 
 | ||||
| When you need context information (for example, using a globally registered component), you can pass the current component context through the `appContext` property. When you need to keep the property responsive, you can use the function to return: | ||||
| When you need context information (for example, using a globally registered component), you can use `Modal.useModal` to get `modal` instance and `contextHolder` node. And put it in your children: | ||||
| 
 | ||||
| ```tsx | ||||
| import { getCurrentInstance } from 'vue'; | ||||
| ```html | ||||
| <template> | ||||
|   <contextHolder /> | ||||
|   <!-- <component :is='contextHolder'/> --> | ||||
| </template> | ||||
| <script setup> | ||||
|   import { Modal } from 'ant-design-vue'; | ||||
|   const [modal, contextHolder] = Modal.useModal(); | ||||
| 
 | ||||
| const appContext = getCurrentInstance().appContext; | ||||
| const title = ref('some message'); | ||||
| Modal.confirm({ | ||||
|   title: () => title.value, // the change of title will update the title in confirm synchronously | ||||
|   appContext, | ||||
| }); | ||||
|   modal.confirm({ | ||||
|     // ... | ||||
|   }); | ||||
| </script> | ||||
| ``` | ||||
|  |  | |||
|  | @ -1,15 +1,16 @@ | |||
| import type { App, Plugin } from 'vue'; | ||||
| import type { ModalFunc, ModalFuncProps } from './Modal'; | ||||
| import Modal, { destroyFns } from './Modal'; | ||||
| import Modal from './Modal'; | ||||
| import confirm, { withWarn, withInfo, withSuccess, withError, withConfirm } from './confirm'; | ||||
| 
 | ||||
| import useModal from './useModal'; | ||||
| import destroyFns from './destroyFns'; | ||||
| export type { ActionButtonProps } from '../_util/ActionButton'; | ||||
| export type { ModalProps, ModalFuncProps } from './Modal'; | ||||
| 
 | ||||
| function modalWarn(props: ModalFuncProps) { | ||||
|   return confirm(withWarn(props)); | ||||
| } | ||||
| 
 | ||||
| Modal.useModal = useModal; | ||||
| Modal.info = function infoFn(props: ModalFuncProps) { | ||||
|   return confirm(withInfo(props)); | ||||
| }; | ||||
|  | @ -60,4 +61,6 @@ export default Modal as typeof Modal & | |||
|     readonly confirm: ModalFunc; | ||||
| 
 | ||||
|     readonly destroyAll: () => void; | ||||
| 
 | ||||
|     readonly useModal: typeof useModal; | ||||
|   }; | ||||
|  |  | |||
|  | @ -120,21 +120,44 @@ router.beforeEach((to, from, next) => { | |||
| }) | ||||
| ``` | ||||
| 
 | ||||
| ### Modal.useModal() | ||||
| 
 | ||||
| 当你需要使用 Context 时,可以通过 `Modal.useModal` 创建一个 `contextHolder` 插入子节点中。通过 hooks 创建的临时 Modal 将会得到 `contextHolder` 所在位置的所有上下文。创建的 `modal` 对象拥有与 [`Modal.method`](#modalmethod) 相同的创建通知方法。 | ||||
| 
 | ||||
| ```html | ||||
| <template> | ||||
|   <contextHolder /> | ||||
|   <!-- <component :is='contextHolder'/> --> | ||||
| </template> | ||||
| <script setup> | ||||
|   import { Modal } from 'ant-design-vue'; | ||||
|   const [modal, contextHolder] = Modal.useModal(); | ||||
| 
 | ||||
|   modal.confirm({ | ||||
|     // ... | ||||
|   }); | ||||
| </script> | ||||
| ``` | ||||
| 
 | ||||
| ## FAQ | ||||
| 
 | ||||
| ### 为什么 Modal 方法不能获取 全局注册组件、context、vuex 等内容和 ConfigProvider `locale/prefixCls/theme` 配置, 以及不能响应式更新数据 ? | ||||
| 
 | ||||
| 直接调用 Modal 方法,组件会通过 `Vue.render` 动态创建新的 Vue 实体。其 context 与当前代码所在 context 并不相同,因而无法获取 context 信息。 | ||||
| 
 | ||||
| 当你需要 context 信息(例如使用全局注册的组件)时,可以通过 `appContext` 属性传递当前组件 context, 当你需要保留属性响应式时,你可以使用函数返回: | ||||
| 当你需要 context 信息(例如使用全局注册的组件)时,可以通过 Modal.useModal 方法会返回 modal 实体以及 contextHolder 节点。将其插入到你需要获取 context 位置即可: | ||||
| 
 | ||||
| ```tsx | ||||
| import { getCurrentInstance } from 'vue'; | ||||
| ```html | ||||
| <template> | ||||
|   <contextHolder /> | ||||
|   <!-- <component :is='contextHolder'/> --> | ||||
| </template> | ||||
| <script setup> | ||||
|   import { Modal } from 'ant-design-vue'; | ||||
|   const [modal, contextHolder] = Modal.useModal(); | ||||
| 
 | ||||
| const appContext = getCurrentInstance().appContext; | ||||
| const title = ref('some message'); | ||||
| Modal.confirm({ | ||||
|   title: () => title.value, // 此时 title 的改变,会同步更新 confirm 中的 title | ||||
|   appContext, | ||||
| }); | ||||
|   modal.confirm({ | ||||
|     // ... | ||||
|   }); | ||||
| </script> | ||||
| ``` | ||||
|  |  | |||
|  | @ -0,0 +1,73 @@ | |||
| import type { PropType } from 'vue'; | ||||
| import { computed, defineComponent } from 'vue'; | ||||
| import { useConfigContextInject } from '../../config-provider/context'; | ||||
| import { useLocaleReceiver } from '../../locale-provider/LocaleReceiver'; | ||||
| import defaultLocale from '../../locale/en_US'; | ||||
| import ConfirmDialog from '../ConfirmDialog'; | ||||
| import type { ModalFuncProps } from '../Modal'; | ||||
| import initDefaultProps from '../../_util/props-util/initDefaultProps'; | ||||
| export interface HookModalProps { | ||||
|   afterClose: () => void; | ||||
|   config: ModalFuncProps; | ||||
|   destroyAction: (...args: any[]) => void; | ||||
|   open: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface HookModalRef { | ||||
|   destroy: () => void; | ||||
|   update: (config: ModalFuncProps) => void; | ||||
| } | ||||
| 
 | ||||
| const comfirmFuncProps = () => ({ | ||||
|   config: Object as PropType<ModalFuncProps>, | ||||
|   afterClose: Function as PropType<() => void>, | ||||
|   destroyAction: Function as PropType<(e: any) => void>, | ||||
|   open: Boolean, | ||||
| }); | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'HookModal', | ||||
|   inheritAttrs: false, | ||||
|   props: initDefaultProps(comfirmFuncProps(), { | ||||
|     config: { | ||||
|       width: 520, | ||||
|       okType: 'primary', | ||||
|     }, | ||||
|   }), | ||||
|   setup(props: HookModalProps, { expose }) { | ||||
|     const open = computed(() => props.open); | ||||
|     const innerConfig = computed(() => props.config); | ||||
|     const { direction, getPrefixCls } = useConfigContextInject(); | ||||
|     const prefixCls = getPrefixCls('modal'); | ||||
|     const rootPrefixCls = getPrefixCls(); | ||||
| 
 | ||||
|     const afterClose = () => { | ||||
|       props?.afterClose(); | ||||
|       innerConfig.value.afterClose?.(); | ||||
|     }; | ||||
| 
 | ||||
|     const close = (...args: any[]) => { | ||||
|       props.destroyAction(...args); | ||||
|     }; | ||||
| 
 | ||||
|     expose({ destroy: close }); | ||||
|     const mergedOkCancel = innerConfig.value.okCancel ?? innerConfig.value.type === 'confirm'; | ||||
|     const [contextLocale] = useLocaleReceiver('Modal', defaultLocale.Modal); | ||||
|     return () => ( | ||||
|       <ConfirmDialog | ||||
|         prefixCls={prefixCls} | ||||
|         rootPrefixCls={rootPrefixCls} | ||||
|         {...innerConfig.value} | ||||
|         close={close} | ||||
|         open={open.value} | ||||
|         afterClose={afterClose} | ||||
|         okText={ | ||||
|           innerConfig.value.okText || | ||||
|           (mergedOkCancel ? contextLocale?.value.okText : contextLocale?.value.justOkText) | ||||
|         } | ||||
|         direction={innerConfig.value.direction || direction.value} | ||||
|         cancelText={innerConfig.value.cancelText || contextLocale?.value.cancelText} | ||||
|       /> | ||||
|     ); | ||||
|   }, | ||||
| }); | ||||
|  | @ -0,0 +1,150 @@ | |||
| import type { ComputedRef, Ref } from 'vue'; | ||||
| import { isRef, unref, computed, defineComponent, ref, watch } from 'vue'; | ||||
| import type { VueNode } from '../../_util/type'; | ||||
| import type { ModalFuncProps } from '../Modal'; | ||||
| import type { HookModalRef } from './HookModal'; | ||||
| import type { ModalStaticFunctions } from '../confirm'; | ||||
| import { withConfirm, withError, withInfo, withSuccess, withWarn } from '../confirm'; | ||||
| 
 | ||||
| import HookModal from './HookModal'; | ||||
| import destroyFns from '../destroyFns'; | ||||
| 
 | ||||
| let uuid = 0; | ||||
| 
 | ||||
| interface ElementsHolderRef { | ||||
|   addModal: (modal: ComputedRef<JSX.Element>) => () => void; | ||||
| } | ||||
| 
 | ||||
| const ElementsHolder = defineComponent({ | ||||
|   name: 'ElementsHolder', | ||||
|   inheritAttrs: false, | ||||
|   setup(_, { expose }) { | ||||
|     const modals = ref<ComputedRef<JSX.Element>[]>([]); | ||||
|     const addModal = (modal: ComputedRef<JSX.Element>) => { | ||||
|       modals.value.push(modal); | ||||
|       return () => { | ||||
|         modals.value = modals.value.filter(currentModal => currentModal !== modal); | ||||
|       }; | ||||
|     }; | ||||
| 
 | ||||
|     expose({ addModal }); | ||||
|     return () => { | ||||
|       return <>{modals.value.map(modal => modal.value)}</>; | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| export type ModalFuncWithRef = (props: Ref<ModalFuncProps> | ModalFuncProps) => { | ||||
|   destroy: () => void; | ||||
|   update: (configUpdate: ModalFuncProps) => void; | ||||
| }; | ||||
| 
 | ||||
| function useModal(): readonly [ | ||||
|   Omit<ModalStaticFunctions<ModalFuncWithRef>, 'warn'>, | ||||
|   () => VueNode, | ||||
| ] { | ||||
|   const holderRef = ref<ElementsHolderRef>(null); | ||||
|   // ========================== Effect ========================== | ||||
|   const actionQueue = ref([]); | ||||
|   watch( | ||||
|     actionQueue, | ||||
|     () => { | ||||
|       if (actionQueue.value.length) { | ||||
|         const cloneQueue = [...actionQueue.value]; | ||||
|         cloneQueue.forEach(action => { | ||||
|           action(); | ||||
|         }); | ||||
|         actionQueue.value = []; | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       immediate: true, | ||||
|     }, | ||||
|   ); | ||||
| 
 | ||||
|   // =========================== Hook =========================== | ||||
|   const getConfirmFunc = (withFunc: (config: ModalFuncProps) => ModalFuncProps) => | ||||
|     function hookConfirm(config: Ref<ModalFuncProps> | ModalFuncProps) { | ||||
|       uuid += 1; | ||||
|       const open = ref(true); | ||||
|       const modalRef = ref<HookModalRef>(null); | ||||
|       const configRef = ref(unref(config)); | ||||
|       const updateConfig = ref({}); | ||||
|       watch( | ||||
|         () => config, | ||||
|         val => { | ||||
|           updateAction({ | ||||
|             ...(isRef(val) ? val.value : val), | ||||
|             ...updateConfig.value, | ||||
|           }); | ||||
|         }, | ||||
|       ); | ||||
|       // eslint-disable-next-line prefer-const | ||||
|       let closeFunc: Function | undefined; | ||||
|       const modal = computed(() => ( | ||||
|         <HookModal | ||||
|           key={`modal-${uuid}`} | ||||
|           config={withFunc(configRef.value)} | ||||
|           ref={modalRef} | ||||
|           open={open.value} | ||||
|           destroyAction={destroyAction} | ||||
|           afterClose={() => { | ||||
|             closeFunc?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )); | ||||
| 
 | ||||
|       closeFunc = holderRef.value?.addModal(modal); | ||||
| 
 | ||||
|       if (closeFunc) { | ||||
|         destroyFns.push(closeFunc); | ||||
|       } | ||||
| 
 | ||||
|       const destroyAction = (...args: any[]) => { | ||||
|         open.value = false; | ||||
|         const triggerCancel = args.some(param => param && param.triggerCancel); | ||||
|         if (configRef.value.onCancel && triggerCancel) { | ||||
|           configRef.value.onCancel(() => {}, ...args.slice(1)); | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       const updateAction = (newConfig: ModalFuncProps) => { | ||||
|         configRef.value = { | ||||
|           ...configRef.value, | ||||
|           ...newConfig, | ||||
|         }; | ||||
|       }; | ||||
| 
 | ||||
|       const destroy = () => { | ||||
|         if (modalRef.value) { | ||||
|           destroyAction(); | ||||
|         } else { | ||||
|           actionQueue.value = [...actionQueue.value, destroyAction]; | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       const update = (newConfig: ModalFuncProps) => { | ||||
|         updateConfig.value = newConfig; | ||||
|         if (modalRef.value) { | ||||
|           updateAction(newConfig); | ||||
|         } else { | ||||
|           actionQueue.value = [...actionQueue.value, () => updateAction(newConfig)]; | ||||
|         } | ||||
|       }; | ||||
|       return { | ||||
|         destroy, | ||||
|         update, | ||||
|       }; | ||||
|     }; | ||||
| 
 | ||||
|   const fns = computed(() => ({ | ||||
|     info: getConfirmFunc(withInfo), | ||||
|     success: getConfirmFunc(withSuccess), | ||||
|     error: getConfirmFunc(withError), | ||||
|     warning: getConfirmFunc(withWarn), | ||||
|     confirm: getConfirmFunc(withConfirm), | ||||
|   })); | ||||
| 
 | ||||
|   return [fns.value, () => <ElementsHolder key="modal-holder" ref={holderRef} />] as const; | ||||
| } | ||||
| 
 | ||||
| export default useModal; | ||||
		Loading…
	
		Reference in New Issue
	
	 Cherry7
						Cherry7