---
url: /blog-vue/quasar-menu-hover-on-open.md
description: 原本想說應該很簡單，仔細探討後發現事情沒有像憨魚想的那麼簡單。(◉◞౪◟◉ )
---

![quasar-menu-hover-on-open](/quasar-menu-hover-on-open.webp){.cover}

# 幫 Quasar Menu 加上 hover 開啟功能

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

原本想說應該很簡單，不是只要偵測 mouseenter 與 mouseleave 就好了嗎？

沒想到多層級 menu 就出事了，因為從第 1 層移動到第 2 層 menu 的瞬間，就會導致第 1 層選單隱藏。

結果，趴！都沒惹，事情果然沒有像憨魚想的那麼簡單。%(◉◞౪◟◉ )%

多說無益，直接來看看成果。

（按下 Run Project 後需要稍等一會兒）

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

::: warning WCAG
hover 開啟 Menu 功能可能會違反 WCAG 某些準則，這點要多注意。
:::

鱈魚：「（省略一千字）...，建議不要做 hover 開啟 Menu 功能 %(⌐■\_■)✧%」

甲方：「我要 %(◉◞౪◟◉ )%」

鱈魚：「好，我做給你 %...(›´ω\`‹ )%」

## 需求

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

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

### 基本需求

* 當滑鼠 hover 在按鈕（或觸發器）上時，Menu 自動開啟。
* 滑鼠從按鈕移出時：
  * 移至 Menu 上，Menu 保持開啟
  * 否則 Menu 關閉

### 多層級單選單

假設開啟了 3 層，則：

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

### 使用需求

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

先來複習一下 [Quasar Menu 用法](https://quasar.dev/vue-components/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>
```

`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 架構圖](/quasar-menu-hover-on-open/menu-hover-架構圖.png)

由於要傳遞未知層級的資料，預計使用 [Vue 的 Provide/Inject](https://vuejs.org/guide/components/provide-inject) 處理。

## 開發

### props 與 hover 邏輯

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

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

::: tip
為了單純一點，這次排除 `target` 和 `modelValue`，有需要再想辦法加進來。
:::

`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` 提供的 `useParentElement` 與 `useElementHover` 簡單完成。

menu 的部分則是因為 `useElementHover` 放入元件沒有作用，所以改用普通的 `mouseenter` 與 `mouseleave` 處理。

::: 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 開啟的邏輯了。

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

完整原始碼[在這裡](https://github.com/Codfisher/blog-quasar-menu-open-on-hover)

想嘗試看看的朋友可以[點這裡](https://stackblitz.com/~/github.com/Codfisher/blog-quasar-menu-open-on-hover?file=src/components/menu-hover/menu-hover.vue)

## 總結 🐟

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