Skip to content

quasar-menu-hover-on-open

幫 Quasar Menu 加上 hover 開啟功能

最近遇到 Menu 想要使用 hover 開啟的需求,不過 Quasar 的 Menu 預設沒有這個功能,只好自行處理了。◝( •ω• )◟

原本想說應該很簡單,仔細探討後發現事情沒有像憨魚想的那麼簡單。(◉◞౪◟◉ )

不僅用於 Quasar Menu

雖然這篇針對 Quasar Menu 開發,但此邏輯沒有綁定 Quasar Menu 特有功能,所以微調後也可用於其他 UI 套件的 Menu,~應該啦 (ゝ∀・)b~。

需求

所有的開發第一步都是釐清需求。

甚麼?你說沒有需求怎麼辦?那就開始通靈吧!(。-`ω´-)

基本需求

  • 當滑鼠 hover 在按鈕(或觸發器)上時,Menu 自動開啟。
  • 滑鼠從按鈕移出時:
    • 移至 Menu 上,Menu 保持開啟
    • 否則 Menu 關閉

多層級單選單

假設開啟了 3 層,則:

  • 滑鼠離開第 3 層回到第 2 層時,第 3 層消失
  • 直接離開第 3 層至頁面,所有選單消失
  • 直接回到第一層,第 2、3 層關閉

使用需求

先來複習一下 Quasar Menu 用法

用法很簡單,只要在預期產生 Menu 的元素內放入 q-menu 即可。

vue
<template>
  <q-btn
    color="primary"
    label="Basic Menu"
  >
    <q-menu>
      <q-list>
        <q-item v-close-popup clickable>
          <q-item-section>New tab</q-item-section>
        </q-item>
        <q-item v-close-popup clickable>
          <q-item-section>New incognito tab</q-item-section>
        </q-item>
      </q-list>
    </q-menu>
  </q-btn>
</template>

計畫開發一個元件,可以無痛替換 Quasar Menu,假設叫做 menu-hover

只要像這樣簡單替換即可。

vue
<template>
  <q-btn
    color="primary"
    label="Basic Menu"
  >
    <menu-hover>
      // ...
    </menu-hover>
  </q-btn>
</template>

架構

主要有兩個工作,概念如下圖所示:

  1. 注入一個紀錄 Menu 層級的變數
  2. root menu 注入收集 child 資料之 function

menu-hover 架構圖

由於要傳遞未知層級的資料,預計使用 Vue 的 Provide/Inject 處理。

開發

props 與 hover 邏輯

現在來實際開發吧,首先從最基本的 props 開始。

因為要可以無痛替換原本的 q-menu,所以需要繼承 q-menu 的 props。

TIP

為了單純一點,這次排除 targetmodelValue,有需要再想辦法加進來。

src/components/menu-hover/menu-hover.vue

vue
<script setup lang="ts">
import type { QMenuProps } from 'quasar'

interface Props extends Omit<QMenuProps, 'modelValue' | 'target'> {
  disableHoverOpen?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  disableHoverOpen: false,
})
</script>
./type./type

接下來是觸發器與 menu 自己本身的 hover 邏輯。

src/components/menu-hover/menu-hover.vue

vue
<template>
  <q-menu
    ref="menuRef"
    @mouseenter="handleMenuHover"
    @mouseleave="handleMenuLeave"
  >
    <slot />
  </q-menu>
</template>

<script setup lang="ts">
// ...

// 辨識元件,Vue 3.5 之後可以直接使用 useId()
const id = crypto.randomUUID()

const triggerEl = useParentElement()
const isTriggerHover = useElementHover(triggerEl, {
  delayLeave: 100,
})

const menuRef = ref<InstanceType<typeof QMenu>>()
const isMenuHover = ref(false)
const handleMenuLeave = debounce(() => {
  isMenuHover.value = false
}, 100)
function handleMenuHover() {
  isMenuHover.value = true
  handleMenuLeave.cancel()
}
</script>

這裡使用 VueUse 提供的 useParentElementuseElementHover 簡單完成。

menu 的部分則是因為 useElementHover 放入元件沒有作用,所以改用普通的 mouseentermouseleave 處理。

TIP

menuRef 與 useElementHover 目前做過以下嘗試。

這樣寫 TS 不開心:

ts
useElementHover(menuRef, {
  delayLeave: 100,
})

這樣寫沒反應:

ts
useElementHover(() => menuRef.value?.$el, {
  delayLeave: 100,
})

若有大大知道怎麼做,還請不吝告訴我!(*´∀`)~♥

注入 level 並蒐集 child menu 資料

先來定義形別資料。

src/components/menu-hover/type.ts

ts
import type { InjectionKey, Ref } from 'vue'

export interface MenuData {
  id: string;
  level: number;
}

export const menuLevelInjectionKey = Symbol('menu-level') as InjectionKey<number>

export const menuInjectionKey = Symbol('menu-hover') as InjectionKey<{
  bindSubmenu: (data: MenuData) => void;
  unbindSubmenu: (id: string) => void;
  submenuList: Ref<MenuData[]>;
}>

並在元件引用。

src/components/menu-hover/menu-hover.vue

vue
// ...

<script setup lang="ts">
import type { MenuData } from './type'
import { computed, inject, provide, ref } from 'vue'
import { injectionKey } from './type'

// ...

/** 只有 root menu 使用,child menu 應該使用 currentSubmenuList */
const submenuList = ref<MenuData[]>([])

const rootProvider = inject(injectionKey, null)
const currentSubmenuList = computed(() => rootProvider?.submenuList.value ?? [])

// 紀錄目前是第幾層 menu
const menuLevel = inject(menuLevelInjectionKey, 0)
provide(menuLevelInjectionKey, menuLevel + 1)

function bindSubmenu(data: MenuData) {
  /** 清掉同一層者,因為同層同時只會顯示一個 */
  submenuList.value = submenuList.value.filter((item) => item.level !== data.level)
  submenuList.value.push(data)
}
function unbindSubmenu(id: string) {
  const index = submenuList.value.findIndex((item) => item.id === id)
  if (index !== -1) {
    submenuList.value.splice(index, 1)
  }
}
// 只有 root menu provide
if (!rootProvider) {
  provide(injectionKey, {
    bindSubmenu,
    unbindSubmenu,
    submenuList,
  })
}
</script>

基本上就是當 child menu 顯示時,將自己的資料傳遞給 root menu,隱藏、消失時移除資料。

並透過 menu-level 紀錄目前是第幾層,這樣 root 與 child 都可以得知目前整體 Menu tree 狀態了。

有狀態後,要判斷 hover 邏輯就簡單多了。

開啟選單

最後就是實際開啟 Menu 的部分了,使用 menuVisible 來控制 Menu 的顯示與隱藏。

有 3 種方式會觸發 Menu 顯示:

  1. 觸發器 hover
  2. Menu hover
  3. 自己的子選單開啟
vue
<template>
  <q-menu
    ref="menuRef"
    v-model="menuVisible"
    v-bind="props"
    @mouseenter="handleMenuHover"
    @mouseleave="handleMenuLeave"
  >
    <slot />
  </q-menu>
</template>

<script setup lang="ts">
// ...

const menuVisible = ref(false)
const hasSubmenuVisible = computed(() => {
  if (menuLevel === 0 && submenuList.value.length > 0) {
    return true
  }

  const visible = currentSubmenuList.value.some(({ level }) => level > menuLevel)

  // 自己也要顯示才算是自己的 submenu
  return visible && menuVisible.value
})

watch(() => [
  isTriggerHover,
  isMenuHover,
  hasSubmenuVisible,
], () => {
  if (props.disableHoverOpen) {
    return
  }

  menuVisible.value = isTriggerHover.value || isMenuHover.value || hasSubmenuVisible.value
}, { deep: true })

watch(menuVisible, (value) => {
  if (value) {
    rootProvider?.bindSubmenu({
      id,
      level: menuLevel,
    })
  }
  else {
    rootProvider?.unbindSubmenu(id)
  }
})

onBeforeUnmount(() => {
  rootProvider?.unbindSubmenu(id)
})
</script>

以上我們完成 hover 開啟的邏輯了。

若有哪個部分解解釋得不夠清楚或有大神知道更簡潔的方法,還請不吝告訴我。੭ ˙ᗜ˙ )੭

完整原始碼在這裡

想嘗試看看的朋友可以點這裡

總結 🐟

  • 微調後可以用於其他 UI 套件的 Menu
  • 使用 Vue 的 Provide/Inject 來傳遞未知層級的資料