Browse Source

feat: modal and drawer locking improve (#5648)

* feat: add `unlock` for modalApi

* fix: modal's close button style in locking

* fix: fix modal's close button disabled on locking

* feat: add `lock` and `unlock` for drawerApi
Netfan 3 weeks ago
parent
commit
f380452ef0

+ 15 - 7
docs/src/components/common-ui/vben-drawer.md

@@ -137,11 +137,19 @@ const [Drawer, drawerApi] = useVbenDrawer({
 
 ### drawerApi
 
-| 方法 | 描述 | 类型 |
-| --- | --- | --- |
+| 方法 | 描述 | 类型 | 版本限制 |
+| --- | --- | --- | --- |
 | setState | 动态设置弹窗状态属性 | `(((prev: ModalState) => Partial<ModalState>)\| Partial<ModalState>)=>drawerApi` |
-| open | 打开弹窗 | `()=>void` |
-| close | 关闭弹窗 | `()=>void` |
-| setData | 设置共享数据 | `<T>(data:T)=>drawerApi` |
-| getData | 获取共享数据 | `<T>()=>T` |
-| useStore | 获取可响应式状态 | - |
+| open | 打开弹窗 | `()=>void` | --- |
+| close | 关闭弹窗 | `()=>void` | --- |
+| setData | 设置共享数据 | `<T>(data:T)=>drawerApi` | --- |
+| getData | 获取共享数据 | `<T>()=>T` | --- |
+| useStore | 获取可响应式状态 | - | --- |
+| lock | 将抽屉标记为提交中,锁定当前状态 | `(isLock:boolean)=>drawerApi` | >5.5.3 |
+| unlock | lock方法的反操作,解除抽屉的锁定状态,也是lock(false)的别名 | `()=>drawerApi` | >5.5.3 |
+
+::: info lock
+
+`lock`方法用于锁定抽屉的状态,一般用于提交数据的过程中防止用户重复提交或者抽屉被意外关闭、表单数据被改变等等。当处于锁定状态时,抽屉的确认按钮会变为loading状态,同时禁用取消按钮和关闭按钮、禁止ESC或者点击遮罩等方式关闭抽屉、开启抽屉的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的抽屉时,会自动解锁。要主动解除这种状态,可以调用`unlock`方法或者再次调用lock方法并传入false参数。
+
+:::

+ 2 - 1
docs/src/components/common-ui/vben-modal.md

@@ -155,9 +155,10 @@ const [Modal, modalApi] = useVbenModal({
 | getData | 获取共享数据 | `<T>()=>T` | - |
 | useStore | 获取可响应式状态 | - | - |
 | lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` | >5.5.2 |
+| unlock | lock方法的反操作,解除弹窗的锁定状态,也是lock(false)的别名 | `()=>modalApi` | >5.5.3 |
 
 ::: info lock
 
-`lock`方法用于锁定当前弹窗的状态,一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时,弹窗的确认按钮会变为loading状态,同时禁用确认按钮、隐藏关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。
+`lock`方法用于锁定当前弹窗的状态,一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时,弹窗的确认按钮会变为loading状态,同时禁用取消按钮和关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。要主动解除这种状态,可以调用`unlock`方法或者再次调用lock方法并传入false参数。
 
 :::

+ 23 - 1
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts

@@ -52,6 +52,7 @@ export class DrawerApi {
       placement: 'right',
       showCancelButton: true,
       showConfirmButton: true,
+      submitting: false,
       title: '',
     };
 
@@ -92,7 +93,11 @@ export class DrawerApi {
     // 如果 onBeforeClose 返回 false,则不关闭弹窗
     const allowClose = this.api.onBeforeClose?.() ?? true;
     if (allowClose) {
-      this.store.setState((prev) => ({ ...prev, isOpen: false }));
+      this.store.setState((prev) => ({
+        ...prev,
+        isOpen: false,
+        submitting: false,
+      }));
     }
   }
 
@@ -100,6 +105,15 @@ export class DrawerApi {
     return (this.sharedData?.payload ?? {}) as T;
   }
 
+  /**
+   * 锁定抽屉状态(用于提交过程中的等待状态)
+   * @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖抽屉内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
+   * @param isLocked 是否锁定
+   */
+  lock(isLocked: boolean = true) {
+    return this.setState({ submitting: isLocked });
+  }
+
   /**
    * 取消操作
    */
@@ -157,4 +171,12 @@ export class DrawerApi {
     }
     return this;
   }
+
+  /**
+   * 解除抽屉的锁定状态
+   * @description 解除由lock方法设置的锁定状态,是lock(false)的别名
+   */
+  unlock() {
+    return this.lock(false);
+  }
 }

+ 6 - 2
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts

@@ -75,12 +75,12 @@ export interface DrawerProps {
    * @default false
    */
   loading?: boolean;
-
   /**
    * 是否显示遮罩
    * @default true
    */
   modal?: boolean;
+
   /**
    * 是否自动聚焦
    */
@@ -89,12 +89,12 @@ export interface DrawerProps {
    * 弹窗遮罩模糊效果
    */
   overlayBlur?: number;
-
   /**
    * 抽屉位置
    * @default right
    */
   placement?: DrawerPlacement;
+
   /**
    * 是否显示取消按钮
    * @default true
@@ -105,6 +105,10 @@ export interface DrawerProps {
    * @default true
    */
   showConfirmButton?: boolean;
+  /**
+   * 提交中(锁定抽屉状态)
+   */
+  submitting?: boolean;
   /**
    * 弹窗标题
    */

+ 18 - 5
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue

@@ -36,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
   appendToMain: false,
   closeIconPlacement: 'right',
   drawerApi: undefined,
+  submitting: false,
   zIndex: 1000,
 });
 
@@ -73,6 +74,7 @@ const {
   placement,
   showCancelButton,
   showConfirmButton,
+  submitting,
   title,
   titleTooltip,
   zIndex,
@@ -91,12 +93,12 @@ watch(
 );
 
 function interactOutside(e: Event) {
-  if (!closeOnClickModal.value) {
+  if (!closeOnClickModal.value || submitting.value) {
     e.preventDefault();
   }
 }
 function escapeKeyDown(e: KeyboardEvent) {
-  if (!closeOnPressEscape.value) {
+  if (!closeOnPressEscape.value || submitting.value) {
     e.preventDefault();
   }
 }
@@ -104,7 +106,11 @@ function escapeKeyDown(e: KeyboardEvent) {
 function pointerDownOutside(e: Event) {
   const target = e.target as HTMLElement;
   const dismissableDrawer = target?.dataset.dismissableDrawer;
-  if (!closeOnClickModal.value || dismissableDrawer !== id) {
+  if (
+    submitting.value ||
+    !closeOnClickModal.value ||
+    dismissableDrawer !== id
+  ) {
     e.preventDefault();
   }
 }
@@ -169,6 +175,7 @@ const getAppendTo = computed(() => {
           <SheetClose
             v-if="closable && closeIconPlacement === 'left'"
             as-child
+            :disabled="submitting"
             class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
           >
             <slot name="close-icon">
@@ -209,6 +216,7 @@ const getAppendTo = computed(() => {
           <SheetClose
             v-if="closable && closeIconPlacement === 'right'"
             as-child
+            :disabled="submitting"
             class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
           >
             <slot name="close-icon">
@@ -233,7 +241,11 @@ const getAppendTo = computed(() => {
           })
         "
       >
-        <VbenLoading v-if="showLoading" class="size-full" spinning />
+        <VbenLoading
+          v-if="showLoading || submitting"
+          class="size-full"
+          spinning
+        />
 
         <slot></slot>
       </div>
@@ -253,6 +265,7 @@ const getAppendTo = computed(() => {
             :is="components.DefaultButton || VbenButton"
             v-if="showCancelButton"
             variant="ghost"
+            :disabled="submitting"
             @click="() => drawerApi?.onCancel()"
           >
             <slot name="cancelText">
@@ -263,7 +276,7 @@ const getAppendTo = computed(() => {
           <component
             :is="components.PrimaryButton || VbenButton"
             v-if="showConfirmButton"
-            :loading="confirmLoading"
+            :loading="confirmLoading || submitting"
             @click="() => drawerApi?.onConfirm()"
           >
             <slot name="confirmText">

+ 8 - 0
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts

@@ -180,4 +180,12 @@ export class ModalApi {
     }
     return this;
   }
+
+  /**
+   * 解除弹窗的锁定状态
+   * @description 解除由lock方法设置的锁定状态,是lock(false)的别名
+   */
+  unlock() {
+    return this.lock(false);
+  }
 }

+ 2 - 1
packages/@core/ui-kit/popup-ui/src/modal/modal.vue

@@ -200,12 +200,13 @@ const getAppendTo = computed(() => {
       "
       :modal="modal"
       :open="state?.isOpen"
-      :show-close="submitting ? false : closable"
+      :show-close="closable"
       :z-index="zIndex"
       :overlay-blur="overlayBlur"
       close-class="top-3"
       @close-auto-focus="handleFocusOutside"
       @closed="() => modalApi?.onClosed()"
+      :close-disabled="submitting"
       @escape-key-down="escapeKeyDown"
       @focus-outside="handleFocusOutside"
       @interact-outside="interactOutside"

+ 3 - 1
packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue

@@ -23,6 +23,7 @@ const props = withDefaults(
       appendTo?: HTMLElement | string;
       class?: ClassType;
       closeClass?: ClassType;
+      closeDisabled?: boolean;
       modal?: boolean;
       open?: boolean;
       overlayBlur?: number;
@@ -30,7 +31,7 @@ const props = withDefaults(
       zIndex?: number;
     }
   >(),
-  { appendTo: 'body', showClose: true },
+  { appendTo: 'body', closeDisabled: false, showClose: true },
 );
 const emits = defineEmits<
   DialogContentEmits & { close: []; closed: []; opened: [] }
@@ -108,6 +109,7 @@ defineExpose({
 
       <DialogClose
         v-if="showClose"
+        :disabled="closeDisabled"
         :class="
           cn(
             'data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none',

+ 9 - 2
playground/src/views/examples/drawer/base-demo.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import { useVbenDrawer } from '@vben/common-ui';
 
-import { message } from 'ant-design-vue';
+import { Button, message } from 'ant-design-vue';
 
 const [Drawer, drawerApi] = useVbenDrawer({
   onCancel() {
@@ -15,12 +15,19 @@ const [Drawer, drawerApi] = useVbenDrawer({
     // drawerApi.close();
   },
 });
+
+function lockDrawer() {
+  drawerApi.lock();
+  setTimeout(() => {
+    drawerApi.unlock();
+  }, 3000);
+}
 </script>
 <template>
   <Drawer title="基础抽屉示例" title-tooltip="标题提示内容">
     <template #extra> extra </template>
     base demo
-
+    <Button type="primary" @click="lockDrawer">锁定抽屉状态</Button>
     <!-- <template #prepend-footer> slot </template> -->
     <!-- <template #append-footer> prepend slot </template> -->
   </Drawer>

+ 9 - 1
playground/src/views/examples/modal/base-demo.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import { useVbenModal } from '@vben/common-ui';
 
-import { message } from 'ant-design-vue';
+import { Button, message } from 'ant-design-vue';
 
 const [Modal, modalApi] = useVbenModal({
   onCancel() {
@@ -18,9 +18,17 @@ const [Modal, modalApi] = useVbenModal({
     message.info('onOpened:打开动画结束');
   },
 });
+
+function lockModal() {
+  modalApi.lock();
+  setTimeout(() => {
+    modalApi.unlock();
+  }, 3000);
+}
 </script>
 <template>
   <Modal class="w-[600px]" title="基础弹窗示例" title-tooltip="标题提示内容">
     base demo
+    <Button type="primary" @click="lockModal">锁定弹窗</Button>
   </Modal>
 </template>