幫 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
即可。
<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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
計畫開發一個元件,可以無痛替換 Quasar Menu,假設叫做 menu-hover
。
只要像這樣簡單替換即可。
<template>
<q-btn
color="primary"
label="Basic Menu"
>
<menu-hover>
// ...
</menu-hover>
</q-btn>
</template>
2
3
4
5
6
7
8
9
10
架構
主要有兩個工作,概念如下圖所示:
- 注入一個紀錄 Menu 層級的變數
- root menu 注入收集 child 資料之 function
由於要傳遞未知層級的資料,預計使用 Vue 的 Provide/Inject 處理。
開發
props 與 hover 邏輯
現在來實際開發吧,首先從最基本的 props 開始。
因為要可以無痛替換原本的 q-menu
,所以需要繼承 q-menu
的 props。
TIP
為了單純一點,這次排除 target
和 modelValue
,有需要再想辦法加進來。
src/components/menu-hover/menu-hover.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
2
3
4
5
6
7
8
9
10
11
接下來是觸發器與 menu 自己本身的 hover 邏輯。
src/components/menu-hover/menu-hover.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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
這裡使用 VueUse
提供的 useParentElement
與 useElementHover
簡單完成。
menu 的部分則是因為 useElementHover
放入元件沒有作用,所以改用普通的 mouseenter
與 mouseleave
處理。
TIP
menuRef 與 useElementHover
目前做過以下嘗試。
這樣寫 TS 不開心:
useElementHover(menuRef, {
delayLeave: 100,
})
2
3
這樣寫沒反應:
useElementHover(() => menuRef.value?.$el, {
delayLeave: 100,
})
2
3
若有大大知道怎麼做,還請不吝告訴我!(*´∀`)~♥
注入 level 並蒐集 child menu 資料
先來定義形別資料。
src/components/menu-hover/type.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[]>;
}>
2
3
4
5
6
7
8
9
10
11
12
13
14
並在元件引用。
src/components/menu-hover/menu-hover.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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
基本上就是當 child menu 顯示時,將自己的資料傳遞給 root menu,隱藏、消失時移除資料。
並透過 menu-level
紀錄目前是第幾層,這樣 root 與 child 都可以得知目前整體 Menu tree 狀態了。
有狀態後,要判斷 hover 邏輯就簡單多了。
開啟選單
最後就是實際開啟 Menu 的部分了,使用 menuVisible
來控制 Menu 的顯示與隱藏。
有 3 種方式會觸發 Menu 顯示:
- 觸發器 hover
- Menu hover
- 自己的子選單開啟
<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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
以上我們完成 hover 開啟的邏輯了。
若有哪個部分解解釋得不夠清楚或有大神知道更簡潔的方法,還請不吝告訴我。੭ ˙ᗜ˙ )੭
完整原始碼在這裡
想嘗試看看的朋友可以點這裡
總結 🐟
- 微調後可以用於其他 UI 套件的 Menu
- 使用 Vue 的 Provide/Inject 來傳遞未知層級的資料