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 type
pull/6527/head
Cherry7 2023-05-03 14:15:34 +08:00 committed by GitHub
parent 0df6abe3a7
commit 69640c0af8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 414 additions and 24 deletions

View File

@ -149,8 +149,6 @@ export interface ModalLocale {
justOkText: string; justOkText: string;
} }
export const destroyFns = [];
export default defineComponent({ export default defineComponent({
compatConfig: { MODE: 3 }, compatConfig: { MODE: 3 },
name: 'AModal', name: 'AModal',
@ -167,6 +165,7 @@ export default defineComponent({
props, props,
); );
const [wrapSSR, hashId] = useStyle(prefixCls); const [wrapSSR, hashId] = useStyle(prefixCls);
warning( warning(
props.visible === undefined, props.visible === undefined,
'Modal', 'Modal',

View File

@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // 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`] = ` exports[`renders ./components/modal/demo/async.vue correctly 1`] = `
<div><button class="ant-btn ant-btn-primary" type="button"> <div><button class="ant-btn ant-btn-primary" type="button">
<!----><span>Open Modal with async logic</span> <!----><span>Open Modal with async logic</span>

View File

@ -1,13 +1,14 @@
import { createVNode, render as vueRender } from 'vue'; import { createVNode, render as vueRender } from 'vue';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
import type { ModalFuncProps } from './Modal'; import type { ModalFuncProps } from './Modal';
import { destroyFns } from './Modal';
import ConfigProvider, { globalConfigForApi } from '../config-provider'; import ConfigProvider, { globalConfigForApi } from '../config-provider';
import omit from '../_util/omit'; import omit from '../_util/omit';
import { getConfirmLocale } from './locale'; import { getConfirmLocale } from './locale';
import destroyFns from './destroyFns';
type ConfigUpdate = ModalFuncProps | ((prevConfig: ModalFuncProps) => ModalFuncProps); type ConfigUpdate = ModalFuncProps | ((prevConfig: ModalFuncProps) => ModalFuncProps);
export type ModalStaticFunctions<T = ModalFunc> = Record<NonNullable<ModalFuncProps['type']>, T>;
export type ModalFunc = (props: ModalFuncProps) => { export type ModalFunc = (props: ModalFuncProps) => {
destroy: () => void; destroy: () => void;

View File

@ -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>

View File

@ -5,6 +5,7 @@
<custom-footer /> <custom-footer />
<confirm /> <confirm />
<info /> <info />
<HookModal />
<locale /> <locale />
<manual /> <manual />
<position /> <position />
@ -31,6 +32,7 @@ import Width from './width.vue';
import Fullscreen from './fullscreen.vue'; import Fullscreen from './fullscreen.vue';
import ButtonProps from './button-props.vue'; import ButtonProps from './button-props.vue';
import modalRenderVue from './modal-render.vue'; import modalRenderVue from './modal-render.vue';
import HookModal from './HookModal.vue';
import CN from '../index.zh-CN.md'; import CN from '../index.zh-CN.md';
import US from '../index.en-US.md'; import US from '../index.en-US.md';
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
@ -52,6 +54,7 @@ export default defineComponent({
ButtonProps, ButtonProps,
Fullscreen, Fullscreen,
modalRenderVue, modalRenderVue,
HookModal,
}, },
setup() { setup() {
return {}; return {};

View File

@ -0,0 +1,2 @@
const destroyFns: Array<Function> = [];
export default destroyFns;

View File

@ -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 ## 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? ### 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. 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 ```html
import { getCurrentInstance } from 'vue'; <template>
<contextHolder />
<!-- <component :is='contextHolder'/> -->
</template>
<script setup>
import { Modal } from 'ant-design-vue';
const [modal, contextHolder] = Modal.useModal();
const appContext = getCurrentInstance().appContext; modal.confirm({
const title = ref('some message'); // ...
Modal.confirm({ });
title: () => title.value, // the change of title will update the title in confirm synchronously </script>
appContext,
});
``` ```

View File

@ -1,15 +1,16 @@
import type { App, Plugin } from 'vue'; import type { App, Plugin } from 'vue';
import type { ModalFunc, ModalFuncProps } from './Modal'; 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 confirm, { withWarn, withInfo, withSuccess, withError, withConfirm } from './confirm';
import useModal from './useModal';
import destroyFns from './destroyFns';
export type { ActionButtonProps } from '../_util/ActionButton'; export type { ActionButtonProps } from '../_util/ActionButton';
export type { ModalProps, ModalFuncProps } from './Modal'; export type { ModalProps, ModalFuncProps } from './Modal';
function modalWarn(props: ModalFuncProps) { function modalWarn(props: ModalFuncProps) {
return confirm(withWarn(props)); return confirm(withWarn(props));
} }
Modal.useModal = useModal;
Modal.info = function infoFn(props: ModalFuncProps) { Modal.info = function infoFn(props: ModalFuncProps) {
return confirm(withInfo(props)); return confirm(withInfo(props));
}; };
@ -60,4 +61,6 @@ export default Modal as typeof Modal &
readonly confirm: ModalFunc; readonly confirm: ModalFunc;
readonly destroyAll: () => void; readonly destroyAll: () => void;
readonly useModal: typeof useModal;
}; };

View File

@ -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 ## FAQ
### 为什么 Modal 方法不能获取 全局注册组件、context、vuex 等内容和 ConfigProvider `locale/prefixCls/theme` 配置, 以及不能响应式更新数据 ### 为什么 Modal 方法不能获取 全局注册组件、context、vuex 等内容和 ConfigProvider `locale/prefixCls/theme` 配置, 以及不能响应式更新数据
直接调用 Modal 方法,组件会通过 `Vue.render` 动态创建新的 Vue 实体。其 context 与当前代码所在 context 并不相同,因而无法获取 context 信息。 直接调用 Modal 方法,组件会通过 `Vue.render` 动态创建新的 Vue 实体。其 context 与当前代码所在 context 并不相同,因而无法获取 context 信息。
当你需要 context 信息(例如使用全局注册的组件)时,可以通过 `appContext` 属性传递当前组件 context, 当你需要保留属性响应式时,你可以使用函数返回: 当你需要 context 信息(例如使用全局注册的组件)时,可以通过 Modal.useModal 方法会返回 modal 实体以及 contextHolder 节点。将其插入到你需要获取 context 位置即可
```tsx ```html
import { getCurrentInstance } from 'vue'; <template>
<contextHolder />
<!-- <component :is='contextHolder'/> -->
</template>
<script setup>
import { Modal } from 'ant-design-vue';
const [modal, contextHolder] = Modal.useModal();
const appContext = getCurrentInstance().appContext; modal.confirm({
const title = ref('some message'); // ...
Modal.confirm({ });
title: () => title.value, // 此时 title 的改变,会同步更新 confirm 中的 title </script>
appContext,
});
``` ```

View File

@ -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}
/>
);
},
});

View File

@ -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;