瀏覽代碼

fix: improve the display of modal and drawer on mobile (#4237)

Vben 8 月之前
父節點
當前提交
fd7b3479b4

+ 1 - 2
.vscode/settings.json

@@ -194,6 +194,5 @@
   "i18n-ally.keystyle": "nested",
   "commentTranslate.multiLineMerge": true,
   "vue.server.hybridMode": true,
-  "typescript.tsdk": "node_modules/typescript/lib",
-  "vitest.disableWorkspaceWarning": true
+  "typescript.tsdk": "node_modules/typescript/lib"
 }

+ 1 - 1
packages/@core/base/design/src/css/global.css

@@ -28,7 +28,7 @@
   #app,
   body,
   html {
-    @apply size-full overscroll-none;
+    @apply !pointer-events-auto size-full overscroll-none;
   }
 
   body {

+ 1 - 1
packages/@core/base/shared/src/utils/__tests__/dom.test.ts

@@ -1,6 +1,6 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
-import { getElementVisibleRect } from '../dom'; // 假设函数位于 utils.ts 中
+import { getElementVisibleRect } from '../dom';
 
 describe('getElementVisibleRect', () => {
   // 设置浏览器视口尺寸的 mock

+ 0 - 1
packages/@core/base/shared/src/utils/__tests__/letter.test.ts

@@ -7,7 +7,6 @@ import {
   toLowerCaseFirstLetter,
 } from '../letter';
 
-// 编写测试用例
 describe('capitalizeFirstLetter', () => {
   it('should capitalize the first letter of a string', () => {
     expect(capitalizeFirstLetter('hello')).toBe('Hello');

+ 1 - 2
packages/@core/base/shared/src/utils/__tests__/unique.test.ts

@@ -13,8 +13,7 @@ describe('uniqueByField', () => {
 
     const uniqueItems = uniqueByField(items, 'id');
 
-    // Assert expected results
-    expect(uniqueItems).toHaveLength(3); // After deduplication, there should be three objects left
+    expect(uniqueItems).toHaveLength(3);
     expect(uniqueItems).toEqual([
       { id: 1, name: 'Item 1' },
       { id: 2, name: 'Item 2' },

+ 1 - 0
packages/@core/composables/src/index.ts

@@ -1,4 +1,5 @@
 export * from './use-content-style';
+export * from './use-is-mobile';
 export * from './use-namespace';
 export * from './use-priority-value';
 export * from './use-sortable';

+ 7 - 0
packages/@core/composables/src/use-is-mobile.ts

@@ -0,0 +1,7 @@
+import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
+
+export function useIsMobile() {
+  const breakpoints = useBreakpoints(breakpointsTailwind);
+  const isMobile = breakpoints.smaller('md');
+  return { isMobile };
+}

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

@@ -30,6 +30,8 @@ export class DrawerApi {
     const defaultState: DrawerState = {
       cancelText: '取消',
       closable: true,
+      closeOnClickModal: true,
+      closeOnPressEscape: true,
       confirmLoading: false,
       confirmText: '确定',
       footer: true,

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

@@ -7,12 +7,21 @@ export interface DrawerProps {
    * 取消按钮文字
    */
   cancelText?: string;
-
   /**
    * 是否显示右上角的关闭按钮
    * @default true
    */
   closable?: boolean;
+  /**
+   * 点击弹窗遮罩是否关闭弹窗
+   * @default true
+   */
+  closeOnClickModal?: boolean;
+  /**
+   * 按下 ESC 键是否关闭弹窗
+   * @default true
+   */
+  closeOnPressEscape?: boolean;
   /**
    * 确定按钮 loading
    * @default false

+ 36 - 10
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import type { DrawerProps, ExtendedDrawerApi } from './drawer';
 
-import { usePriorityValue } from '@vben-core/composables';
+import { useIsMobile, usePriorityValue } from '@vben-core/composables';
 import { Info, X } from '@vben-core/icons';
 import {
   Sheet,
@@ -31,6 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
   drawerApi: undefined,
 });
 
+const { isMobile } = useIsMobile();
 const state = props.drawerApi?.useStore?.();
 
 const title = usePriorityValue('title', props, state);
@@ -43,6 +44,27 @@ const modal = usePriorityValue('modal', props, state);
 const confirmLoading = usePriorityValue('confirmLoading', props, state);
 const cancelText = usePriorityValue('cancelText', props, state);
 const confirmText = usePriorityValue('confirmText', props, state);
+const closeOnClickModal = usePriorityValue('closeOnClickModal', props, state);
+const closeOnPressEscape = usePriorityValue('closeOnPressEscape', props, state);
+
+function interactOutside(e: Event) {
+  if (!closeOnClickModal.value) {
+    e.preventDefault();
+  }
+}
+function escapeKeyDown(e: KeyboardEvent) {
+  if (!closeOnPressEscape.value) {
+    e.preventDefault();
+  }
+}
+// pointer-down-outside
+function pointerDownOutside(e: Event) {
+  const target = e.target as HTMLElement;
+  const isDismissableModal = !!target?.dataset.dismissableModal;
+  if (!closeOnClickModal.value || !isDismissableModal) {
+    e.preventDefault();
+  }
+}
 </script>
 <template>
   <Sheet
@@ -50,7 +72,16 @@ const confirmText = usePriorityValue('confirmText', props, state);
     :open="state?.isOpen"
     @update:open="() => drawerApi?.close()"
   >
-    <SheetContent :class="cn('flex w-[520px] flex-col', props.class, {})">
+    <SheetContent
+      :class="
+        cn('flex w-[520px] flex-col', props.class, {
+          '!w-full': isMobile,
+        })
+      "
+      @escape-key-down="escapeKeyDown"
+      @interact-outside="interactOutside"
+      @pointer-down-outside="pointerDownOutside"
+    >
       <SheetHeader
         :class="
           cn('!flex flex-row items-center justify-between border-b px-6 py-5', {
@@ -59,7 +90,7 @@ const confirmText = usePriorityValue('confirmText', props, state);
         "
       >
         <div>
-          <SheetTitle v-if="title">
+          <SheetTitle v-if="title" class="text-left">
             <slot name="title">
               {{ title }}
 
@@ -111,22 +142,17 @@ const confirmText = usePriorityValue('confirmText', props, state);
 
       <SheetFooter
         v-if="showFooter"
-        class="w-full items-center border-t p-2 px-3"
+        class="w-full flex-row items-center justify-end border-t p-2 px-3"
       >
         <slot name="prepend-footer"></slot>
         <slot name="footer">
-          <VbenButton
-            size="sm"
-            variant="ghost"
-            @click="() => drawerApi?.onCancel()"
-          >
+          <VbenButton variant="ghost" @click="() => drawerApi?.onCancel()">
             <slot name="cancelText">
               {{ cancelText }}
             </slot>
           </VbenButton>
           <VbenButton
             :loading="confirmLoading"
-            size="sm"
             @click="() => drawerApi?.onConfirm()"
           >
             <slot name="confirmText">

+ 27 - 13
packages/@core/ui-kit/popup-ui/src/modal/modal.vue

@@ -3,7 +3,7 @@ import type { ExtendedModalApi, ModalProps } from './modal';
 
 import { computed, nextTick, ref, watch } from 'vue';
 
-import { usePriorityValue } from '@vben-core/composables';
+import { useIsMobile, usePriorityValue } from '@vben-core/composables';
 import { Expand, Info, Shrink } from '@vben-core/icons';
 import {
   Dialog,
@@ -46,6 +46,7 @@ const dialogRef = ref();
 const headerRef = ref();
 const footerRef = ref();
 
+const { isMobile } = useIsMobile();
 // const { height: headerHeight } = useElementSize(headerRef);
 // const { height: footerHeight } = useElementSize(footerRef);
 const state = props.modalApi?.useStore?.();
@@ -66,7 +67,11 @@ const draggable = usePriorityValue('draggable', props, state);
 const fullscreenButton = usePriorityValue('fullscreenButton', props, state);
 const closeOnClickModal = usePriorityValue('closeOnClickModal', props, state);
 const closeOnPressEscape = usePriorityValue('closeOnPressEscape', props, state);
-const shouldDraggable = computed(() => draggable.value && !fullscreen.value);
+
+const shouldFullscreen = computed(() => fullscreen.value || isMobile.value);
+const shouldDraggable = computed(
+  () => draggable.value && !shouldFullscreen.value,
+);
 
 const { dragging } = useModalDraggable(dialogRef, headerRef, shouldDraggable);
 
@@ -114,6 +119,14 @@ function escapeKeyDown(e: KeyboardEvent) {
     e.preventDefault();
   }
 }
+// pointer-down-outside
+function pointerDownOutside(e: Event) {
+  const target = e.target as HTMLElement;
+  const isDismissableModal = !!target?.dataset.dismissableModal;
+  if (!closeOnClickModal.value || !isDismissableModal) {
+    e.preventDefault();
+  }
+}
 </script>
 <template>
   <Dialog
@@ -133,8 +146,8 @@ function escapeKeyDown(e: KeyboardEvent) {
           props.class,
           {
             'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0':
-              fullscreen,
-            'top-1/2 -translate-y-1/2': centered && !fullscreen,
+              shouldFullscreen,
+            'top-1/2 -translate-y-1/2': centered && !shouldFullscreen,
             'duration-300': !dragging,
           },
         )
@@ -143,6 +156,7 @@ function escapeKeyDown(e: KeyboardEvent) {
       close-class="top-4"
       @escape-key-down="escapeKeyDown"
       @interact-outside="interactOutside"
+      @pointer-down-outside="pointerDownOutside"
     >
       <DialogHeader
         ref="headerRef"
@@ -156,7 +170,7 @@ function escapeKeyDown(e: KeyboardEvent) {
           )
         "
       >
-        <DialogTitle v-if="title">
+        <DialogTitle v-if="title" class="text-left">
           <slot name="title">
             {{ title }}
 
@@ -191,7 +205,7 @@ function escapeKeyDown(e: KeyboardEvent) {
 
       <VbenIconButton
         v-if="fullscreenButton"
-        class="hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-10 top-4 size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
+        class="hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-10 top-4 hidden size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none sm:block"
         @click="handleFullscreen"
       >
         <Shrink v-if="fullscreen" class="size-3.5" />
@@ -201,22 +215,22 @@ function escapeKeyDown(e: KeyboardEvent) {
       <DialogFooter
         v-if="showFooter"
         ref="footerRef"
-        :class="cn('items-center border-t p-2', props.footerClass)"
+        :class="
+          cn(
+            'flex-row items-center justify-end border-t p-2',
+            props.footerClass,
+          )
+        "
       >
         <slot name="prepend-footer"></slot>
         <slot name="footer">
-          <VbenButton
-            size="sm"
-            variant="ghost"
-            @click="() => modalApi?.onCancel()"
-          >
+          <VbenButton variant="ghost" @click="() => modalApi?.onCancel()">
             <slot name="cancelText">
               {{ cancelText }}
             </slot>
           </VbenButton>
           <VbenButton
             :loading="confirmLoading"
-            size="sm"
             @click="() => modalApi?.onConfirm()"
           >
             <slot name="confirmText">

+ 1 - 1
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts

@@ -94,7 +94,7 @@ async function checkProps(api: ExtendedModalApi, attrs: Record<string, any>) {
     if (stateKeys.has(attr)) {
       // connectedComponent存在时,不要传入Modal的props,会造成复杂度提升,如果你需要修改Modal的props,请使用 useModal 或者api
       console.warn(
-        `[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useModal or api.`,
+        `[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useVbenModal or api.`,
       );
     }
   }

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

@@ -45,6 +45,7 @@ defineExpose({
   <DialogPortal>
     <DialogOverlay
       class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-overlay fixed inset-0 z-[1000] backdrop-blur-sm"
+      data-dismissable-modal="true"
       @click="() => emits('close')"
     />
     <DialogContent

+ 1 - 0
packages/@core/ui-kit/shadcn-ui/src/components/ui/sheet/SheetContent.vue

@@ -40,6 +40,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
   <DialogPortal>
     <DialogOverlay
       class="bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[1000]"
+      data-dismissable-modal="true"
     />
     <DialogContent
       :class="cn(sheetVariants({ side }), 'z-[1000]', props.class)"

+ 1 - 1
packages/effects/layouts/src/widgets/lock-screen/lock-screen.vue

@@ -159,7 +159,7 @@ function toggleUnlockForm() {
     </transition>
 
     <div
-      class="enter-y absolute bottom-5 w-full text-center text-gray-300 xl:text-xl 2xl:text-3xl"
+      class="enter-y absolute bottom-5 w-full text-center xl:text-xl 2xl:text-3xl"
     >
       <div v-if="showUnlockForm" class="enter-x mb-2 text-3xl">
         {{ hour }}:{{ minute }} <span class="text-lg">{{ meridiem }}</span>

+ 1 - 1
packages/effects/layouts/src/widgets/preferences/preferences.vue

@@ -55,7 +55,7 @@ const listen = computed(() => {
 </script>
 <template>
   <div>
-    <Drawer v-bind="attrs" v-on="listen" />
+    <Drawer v-bind="{ ...$attrs, ...attrs }" v-on="listen" />
 
     <div @click="() => drawerApi.open()">
       <slot>

+ 3 - 0
packages/utils/src/helpers/get-popup-container.ts

@@ -0,0 +1,3 @@
+export function getPopupContainer(node?: HTMLElement): HTMLElement {
+  return (node?.parentNode as HTMLElement) ?? document.body;
+}

+ 1 - 0
packages/utils/src/helpers/index.ts

@@ -2,6 +2,7 @@ export * from './find-menu-by-path';
 export * from './generate-menus';
 export * from './generate-routes-backend';
 export * from './generate-routes-frontend';
+export * from './get-popup-container';
 export * from './merge-route-modules';
 export * from './reset-routes';
 export * from './unmount-global-loading';

+ 3 - 0
vitest.workspace.ts

@@ -0,0 +1,3 @@
+import { defineWorkspace } from 'vitest/config';
+
+export default defineWorkspace(['vitest.config.ts']);