瀏覽代碼

feat: user-dropdown support `hover` trigger (#5143)

* feat: user-dropdown support `hover` trigger

* fix: modified type declaration
Netfan 4 月之前
父節點
當前提交
f446cbf9e5

+ 1 - 0
packages/effects/hooks/package.json

@@ -25,6 +25,7 @@
     "@vben/stores": "workspace:*",
     "@vben/types": "workspace:*",
     "@vben/utils": "workspace:*",
+    "@vueuse/core": "catalog:",
     "vue": "catalog:",
     "vue-router": "catalog:",
     "watermark-js-plus": "catalog:"

+ 1 - 0
packages/effects/hooks/src/index.ts

@@ -1,6 +1,7 @@
 export * from './use-app-config';
 export * from './use-content-maximize';
 export * from './use-design-tokens';
+export * from './use-hover-toggle';
 export * from './use-pagination';
 export * from './use-refresh';
 export * from './use-tabs';

+ 63 - 0
packages/effects/hooks/src/use-hover-toggle.ts

@@ -0,0 +1,63 @@
+import type { Arrayable, MaybeElementRef } from '@vueuse/core';
+
+import { computed, onUnmounted, ref, watch } from 'vue';
+import type { Ref } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useMouseInElement } from '@vueuse/core';
+
+/**
+ * 监测鼠标是否在元素内部,如果在元素内部则返回 true,否则返回 false
+ * @param refElement 所有需要检测的元素。如果提供了一个数组,那么鼠标在任何一个元素内部都会返回 true
+ * @param delay 延迟更新状态的时间
+ * @returns 返回一个数组,第一个元素是一个 ref,表示鼠标是否在元素内部,第二个元素是一个控制器,可以通过 enable 和 disable 方法来控制监听器的启用和禁用
+ */
+export function useHoverToggle(
+  refElement: Arrayable<MaybeElementRef>,
+  delay: (() => number) | number = 500,
+) {
+  const isOutsides: Array<Ref<boolean>> = [];
+  const value = ref(false);
+  const timer = ref<ReturnType<typeof setTimeout> | undefined>();
+  const refs = Array.isArray(refElement) ? refElement : [refElement];
+  refs.forEach((refEle) => {
+    const listener = useMouseInElement(refEle, { handleOutside: true });
+    isOutsides.push(listener.isOutside);
+  });
+  const isOutsideAll = computed(() => isOutsides.every((v) => v.value));
+
+  function setValueDelay(val: boolean) {
+    timer.value && clearTimeout(timer.value);
+    timer.value = setTimeout(
+      () => {
+        value.value = val;
+        timer.value = undefined;
+      },
+      isFunction(delay) ? delay() : delay,
+    );
+  }
+
+  const watcher = watch(
+    isOutsideAll,
+    (val) => {
+      setValueDelay(!val);
+    },
+    { immediate: true },
+  );
+
+  const controller = {
+    enable() {
+      watcher.resume();
+    },
+    disable() {
+      watcher.pause();
+    },
+  };
+
+  onUnmounted(() => {
+    timer.value && clearTimeout(timer.value);
+  });
+
+  return [value, controller] as [typeof value, typeof controller];
+}

+ 90 - 61
packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue

@@ -2,8 +2,9 @@
 import type { AnyFunction } from '@vben/types';
 
 import type { Component } from 'vue';
-import { computed, ref } from 'vue';
+import { computed, useTemplateRef, watch } from 'vue';
 
+import { useHoverToggle } from '@vben/hooks';
 import { LockKeyhole, LogOut } from '@vben/icons';
 import { $t } from '@vben/locales';
 import { preferences, usePreferences } from '@vben/preferences';
@@ -53,6 +54,10 @@ interface Props {
    * 文本
    */
   text?: string;
+  /** 触发方式 */
+  trigger?: 'both' | 'click' | 'hover';
+  /** hover触发时,延迟响应的时间 */
+  hoverDelay?: number;
 }
 
 defineOptions({
@@ -67,10 +72,11 @@ const props = withDefaults(defineProps<Props>(), {
   showShortcutKey: true,
   tagText: '',
   text: '',
+  trigger: 'click',
+  hoverDelay: 500,
 });
 
 const emit = defineEmits<{ logout: [] }>();
-const openPopover = ref(false);
 
 const { globalLockScreenShortcutKey, globalLogoutShortcutKey } =
   usePreferences();
@@ -84,6 +90,27 @@ const [LogoutModal, logoutModalApi] = useVbenModal({
   },
 });
 
+const refTrigger = useTemplateRef('refTrigger');
+const refContent = useTemplateRef('refContent');
+const [openPopover, hoverWatcher] = useHoverToggle(
+  [refTrigger, refContent],
+  () => props.hoverDelay,
+);
+
+watch(
+  () => props.trigger === 'hover' || props.trigger === 'both',
+  (val) => {
+    if (val) {
+      hoverWatcher.enable();
+    } else {
+      hoverWatcher.disable();
+    }
+  },
+  {
+    immediate: true,
+  },
+);
+
 const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
 
 const enableLogoutShortcutKey = computed(() => {
@@ -155,8 +182,8 @@ if (enableShortcutKey.value) {
     {{ $t('ui.widgets.logoutTip') }}
   </LogoutModal>
 
-  <DropdownMenu>
-    <DropdownMenuTrigger>
+  <DropdownMenu v-model:open="openPopover">
+    <DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
       <div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full p-1.5">
         <div class="hover:text-accent-foreground flex-center">
           <VbenAvatar :alt="text" :src="avatar" class="size-8" dot />
@@ -164,64 +191,66 @@ if (enableShortcutKey.value) {
       </div>
     </DropdownMenuTrigger>
     <DropdownMenuContent class="mr-2 min-w-[240px] p-0 pb-1">
-      <DropdownMenuLabel class="flex items-center p-3">
-        <VbenAvatar
-          :alt="text"
-          :src="avatar"
-          class="size-12"
-          dot
-          dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
-        />
-        <div class="ml-2 w-full">
-          <div
-            v-if="tagText || text || $slots.tagText"
-            class="text-foreground mb-1 flex items-center text-sm font-medium"
-          >
-            {{ text }}
-            <slot name="tagText">
-              <Badge v-if="tagText" class="ml-2 text-green-400">
-                {{ tagText }}
-              </Badge>
-            </slot>
-          </div>
-          <div class="text-muted-foreground text-xs font-normal">
-            {{ description }}
+      <div ref="refContent">
+        <DropdownMenuLabel class="flex items-center p-3">
+          <VbenAvatar
+            :alt="text"
+            :src="avatar"
+            class="size-12"
+            dot
+            dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
+          />
+          <div class="ml-2 w-full">
+            <div
+              v-if="tagText || text || $slots.tagText"
+              class="text-foreground mb-1 flex items-center text-sm font-medium"
+            >
+              {{ text }}
+              <slot name="tagText">
+                <Badge v-if="tagText" class="ml-2 text-green-400">
+                  {{ tagText }}
+                </Badge>
+              </slot>
+            </div>
+            <div class="text-muted-foreground text-xs font-normal">
+              {{ description }}
+            </div>
           </div>
-        </div>
-      </DropdownMenuLabel>
-      <DropdownMenuSeparator v-if="menus?.length" />
-      <DropdownMenuItem
-        v-for="menu in menus"
-        :key="menu.text"
-        class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
-        @click="menu.handler"
-      >
-        <VbenIcon :icon="menu.icon" class="mr-2 size-4" />
-        {{ menu.text }}
-      </DropdownMenuItem>
-      <DropdownMenuSeparator />
-      <DropdownMenuItem
-        v-if="preferences.widget.lockScreen"
-        class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
-        @click="handleOpenLock"
-      >
-        <LockKeyhole class="mr-2 size-4" />
-        {{ $t('ui.widgets.lockScreen.title') }}
-        <DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
-          {{ altView }} L
-        </DropdownMenuShortcut>
-      </DropdownMenuItem>
-      <DropdownMenuSeparator v-if="preferences.widget.lockScreen" />
-      <DropdownMenuItem
-        class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
-        @click="handleLogout"
-      >
-        <LogOut class="mr-2 size-4" />
-        {{ $t('common.logout') }}
-        <DropdownMenuShortcut v-if="enableLogoutShortcutKey">
-          {{ altView }} Q
-        </DropdownMenuShortcut>
-      </DropdownMenuItem>
+        </DropdownMenuLabel>
+        <DropdownMenuSeparator v-if="menus?.length" />
+        <DropdownMenuItem
+          v-for="menu in menus"
+          :key="menu.text"
+          class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
+          @click="menu.handler"
+        >
+          <VbenIcon :icon="menu.icon" class="mr-2 size-4" />
+          {{ menu.text }}
+        </DropdownMenuItem>
+        <DropdownMenuSeparator />
+        <DropdownMenuItem
+          v-if="preferences.widget.lockScreen"
+          class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
+          @click="handleOpenLock"
+        >
+          <LockKeyhole class="mr-2 size-4" />
+          {{ $t('ui.widgets.lockScreen.title') }}
+          <DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
+            {{ altView }} L
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+        <DropdownMenuSeparator v-if="preferences.widget.lockScreen" />
+        <DropdownMenuItem
+          class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
+          @click="handleLogout"
+        >
+          <LogOut class="mr-2 size-4" />
+          {{ $t('common.logout') }}
+          <DropdownMenuShortcut v-if="enableLogoutShortcutKey">
+            {{ altView }} Q
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+      </div>
     </DropdownMenuContent>
   </DropdownMenu>
 </template>

+ 1 - 0
playground/src/layouts/basic.vue

@@ -132,6 +132,7 @@ watch(
         :text="userStore.userInfo?.realName"
         description="ann.vben@gmail.com"
         tag-text="Pro"
+        trigger="both"
         @logout="handleLogout"
       />
     </template>

+ 4 - 1
pnpm-lock.yaml

@@ -1548,6 +1548,9 @@ importers:
       '@vben/utils':
         specifier: workspace:*
         version: link:../../utils
+      '@vueuse/core':
+        specifier: 'catalog:'
+        version: 12.0.0(typescript@5.7.2)
       vue:
         specifier: ^3.5.13
         version: 3.5.13(typescript@5.7.2)
@@ -10732,7 +10735,7 @@ snapshots:
       '@babel/core': 7.26.0
       '@babel/helper-compilation-targets': 7.25.9
       '@babel/helper-plugin-utils': 7.25.9
-      debug: 4.3.7(supports-color@9.4.0)
+      debug: 4.4.0
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies: