Dropdown 是一個用於顯示和選擇選項的 UI 元件,通常由一個觸發按鈕或文字組成,當使用者點擊該按鈕或文字時,會展開一個選單列表,列出多個選項供使用者選擇。選項可以是簡單的文字或帶有 Icon 的項目,使用者可以通過點擊選單中的選項來進行選擇,選擇後選單通常會自動收起。
<AtomicDropdown>
與之後會實作的 <AtomicSelect>
有一些差異。<AtomicDropdown>
是一個可以顯示彈出選單的元件,點擊選單裡面的選項可能會切換頁面,也可能會觸發像是新增、修改、刪除的操作;<AtomicSelect>
是一個用於選擇選項的元件,它通常用於表單的控制元件。換句話說,<AtomicDropdown>
是一個 UI (或 Navigation)元件,而 <AtomicSelect>
是一個表單元件。
在上一篇文章中,我們已經實作了 <AtomicPopover>
,這一篇我們將以它為基礎,實作一個 <AtomicDropdown>
元件。
在開始實作前,我們先研究各個 UI Library 的 Dropdown 元件是如何設計的。
Element Plus
<template>
<ElDropdown trigger="click">
<span>
Dropdown List
<ElIcon>
<ArrowDown />
</ElIcon>
</span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>Action 1</ElDropdownItem>
<ElDropdownItem>Action 2</ElDropdownItem>
<ElDropdownItem>Action 3</ElDropdownItem>
<ElDropdownItem disabled>Action 4</ElDropdownItem>
<ElDropdownItem divided>Action 5</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
Element Plus 的 <ElDropdown>
使用方式大致與 <ElPopover>
相似,只是在 <ElDropdown>
另外提供了 <ElDropdownMenu>
與 <ElDropdownItem>
,分別用來包裹選單與選項。
Vuetify
<template>
<VMenu
:location="location"
offset="8"
open-on-hover
>
<template v-slot:activator="{ props }">
<VBtn
color="primary"
v-bind="props"
>
Activator slot
</VBtn>
</template>
<VList>
<VListItem
v-for="(item, index) in items"
:key="index"
>
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</template>
在 Vuetify 中相似功能的元件叫做 <VMenu>
,這在 <AtomicPopover>
的實作中已有提到。Vuetify 的 <VMenu>
也提供了 <VList>
與 <VListItem>
,來分別包裹選單與選項。
Nuxt UI
<template>
<UDropdown :items="items" :popper="{ placement: 'bottom-start' }">
<UButton color="white" label="Options" trailing-icon="i-heroicons-chevron-down-20-solid" />
</UDropdown>
</template>
Nuxt UI 的 <UDropdown>
在使用上就單純很多,default
slot 提供給開發人員自定義按鈕外觀,items
傳入一個二維陣列,提供選單選項。如果要設定 Menu 的定位則用 popper
設定。
相較於 Element Plus 與 Vuetify 的設計,Nuxt UI 的 <UDropdown>
使用方式是我比較偏好的,開發人員不需要使用各種不同的元件來包裹選單與選項,只需傳入一個選單資料即可。但在某些特殊樣式需求下,Element Plus 與 Vuetify 的設計有更高的靈活性。Nuxt UI 的設計若要達到這樣的彈性,必須盡可能地將各種可能性納入 <UDropdown>
元件內部實作,或是使用 slot 讓開發人員自定義每個項目。
綜合以上並結合自身經驗,我們統整出 <AtomicDropdown>
的功能:
default
slot 提供觸發 Popover 的元素。items
提供選單選項。trigger
來決定 Popover 的觸發事件。offset
來調整 Popover 的位置偏移。menuitem
slot 來自定義選項內容。使用結構如下:
<template>
<AtomicDropdown
:items="items"
:placement="placement"
:trigger="trigger"
:offset="offset"
>
<AtomicButton>Click to activate</AtomicButton>
</AtomicDropdown>
</template>
首先,我們將需求中提到的功能整理成 props
與 emit
的介面,我們會需要下列屬性:
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
items | AtomicDropdownItem[] |
控制 Menu 的顯示與否 | |
placement | top , right , bottom , left , top-start , top-end , right-start , right-end , bottom-start , bottom-end , left-start , left-end |
bottom-start |
Menu 的位置 |
trigger | click , hover |
click |
觸發 Menu 的事件 |
offset | number , Partial<{ mainAxis: number; crossAxis: number; }> |
8 |
Menu 與觸發元素的距離 |
disabled | boolean |
false |
是否禁用 Menu |
type AtomicPopoverProps = ComponentProps<typeof AtomicPopover>;
interface AtomicDropdownItem {
label: string;
value: any;
onClick?(value: any): void;
disabled?: boolean;
}
interface AtomicDropdownProps {
items: AtomicDropdownItem[];
placement?: AtomicPopoverProps['placement'];
offset?: AtomicPopoverProps['offset'];
trigger?: 'click' | 'hover';
disabled?: boolean;
}
const props = withDefaults(defineProps<AtomicDropdownProps>(), {
items: () => [],
placement: 'bottom-start',
offset: 8,
});
<AtomicDropdown>
會自行管理開關的狀態,我們並不打算將 <AtomicDropdown>
設計為受控元件,因為絕大多數時候開發人員並不在意 <AtomicDropdown>
是否被開啟或關閉。
const active = ref(false);
我們先將 props.items
稍作整理,方便我們套用到模板上。
const itemsCompose = computed(() => {
return props.items.map(item => {
const onClick = () => {
if (item.disabled || !isFunction(item.onClick)) return;
item.onClick(item.value);
};
return {
...item,
onClick,
};
});
});
<template>
<AtomicPopover
v-model="active"
:disabled="disabled"
:offset="offset"
:placement="placement"
:trigger="trigger"
>
<template #reference>
<slot name="default" />
</template>
<ul class="atomic-dropdown">
<li
v-for="item in itemsCompose"
:key="item.value"
class="atomic-dropdown__menuitem"
@click="item.onClick"
>
{{ item.label }}
</li>
</ul>
</AtomicPopover>
</template>
到這裡已經可以使用 <AtomicDropdown>
元件了,但有個問題,點擊選項後無法控制 Menu 是否關閉。
為了在點擊選項後自動關閉 Menu,我們可以在 items
的 onClick
裡加上一個 close
函式,用來關閉 Menu。
const itemsCompose = computed(() => {
return props.items.map(item => {
const onClick = () => {
if (item.disabled || !isFunction(item.onClick)) return;
item.onClick(item.value);
active.value = false;
};
return {
...item,
onClick,
};
});
});
不過這樣一來,使用者點擊選項後 Menu 必定會關閉,這樣的設計在某些情境下可能會造成限制,例如需求可能是想在點擊選項後執行一些操作,然後再手動關閉 Menu。
我們可以讓 items
的 onClick
多接收第二個參數,這個參數是一個函式,當使用者點擊選項後,可以呼叫這個函式來關閉 Menu。
const onClick = (value: string, close: () => void) => {
// Do something
close();
};
這樣一來我們就要調整 itemsCompose
的實作。
const active = ref(false);
const close = () => (active.value = false);
const itemsCompose = computed(() => {
return props.items.map(item => {
const onClick = () => {
if (item.disabled || !isFunction(item.onClick)) return;
item.onClick(item.value, close);
};
return {
...item,
onClick,
};
});
});
但如果大多數情境下只需點擊選項就關閉 Menu,開發人員還要每次都自己呼叫 close
,未免太麻煩。我們可以參考 <AtomicTabs>
的做法,如果開發人員有使用到第二個參數,則認定開發人員想要自己控制是否關閉,如果沒有,則自動關閉 Menu。
為了實現這個功能,我們再次調整 itemsCompose
的實作。
const itemsCompose = computed(() => {
return props.items.map(item => {
const onClick = () => {
if (item.disabled || !isFunction(item.onClick)) return;
if (item.onClick.length <= 1) {
item.onClick(item.value, noop);
close();
return;
}
return item.onClick(item.value, close);
};
return {
...item,
onClick,
};
});
});
這樣如果開發人員傳入的 onClick
有接收第二個參數,元件就不會自動關閉,將控制權交給開發人員,反之則會自動關閉。
接著我們來處理客製化的需求,我們可能會遇到開發人員想在每個 MenuItem 上加上一些客製化的內容,這時我們可以使用 menuitem
slot 來自定義選單的內容。
<template>
<li
v-for="item in itemsCompose"
:key="item.value"
class="atomic-dropdown__menuitem"
@click="item.onClick"
>
<slot
:disabled="item.disabled"
:label="item.label"
name="menuitem"
:value="item.value"
>
{{ item.label }}
</slot>
</li>
</template>
這樣在畫面上可以這樣使用:
<AtomicDropdown :items="items">
<button type="button">
<MoreSvg />
</button>
<template #menuitem="{ label }">
<div class="flex justify-between w-40">
<span>{{ label }}</span>
</div>
</template>
</AtomicDropdown>
在設計上,我們可以在 items
的每個物件上加上一個 context
屬性,並允許開發人員傳入任何他們想要使用的資料,這會有助於使用 slot 判斷哪個選項需要加入哪些客製化內容。
interface AtomicDropdownItem {
// 略
context?: any;
}
<li
v-for="item in itemsCompose"
:key="item.value"
class="atomic-dropdown__menuitem"
@click="item.onClick"
>
<slot
:context="item.context"
:disabled="item.disabled"
:label="item.label"
name="menuitem"
:value="item.value"
>
{{ item.label }}
</slot>
</li>
這樣我們就完成了 <AtomicDropdown>
的實作。
如果可以使用鍵盤操作就更好了,在無障礙實作指南裡面有提到關於 Listbox 的 Pattern,我們可以參考這個指南來實作 <AtomicDropdown>
元件的鍵盤操作功能。
我們在實作 <AtomicTabs>
時有寫了 moveFocus
、nextItem
與 previousItem
這三個 function,在這裡我們可以重複利用這些 function。
import { moveFocus, nextItem, previousItem } from '~/utils/dom';
const onMenuKeydown = (event: KeyboardEvent) => {
const container = menuRef.value as HTMLElement;
const currentFocus = document.activeElement as HTMLElement;
if (!container) return;
switch (event.key) {
case 'Tab':
event.preventDefault();
active.value = false;
break;
case 'ArrowDown':
event.preventDefault();
moveFocus(container, currentFocus, nextItem);
break;
case 'ArrowUp':
event.preventDefault();
moveFocus(container, currentFocus, previousItem);
break;
case 'Home':
event.preventDefault();
moveFocus(container, null, nextItem);
break;
case 'End':
event.preventDefault();
moveFocus(container, null, previousItem);
break;
}
};
我們將 onMenuKeydown
事件綁定在 Menu 上,這樣儘管使使用者聚焦在 MenuItem 上,我們也可以透過事件冒泡機制監聽到鍵盤事件。
<ul
ref="menu"
class="atomic-dropdown"
@keydown="onMenuKeydown"
>
<!-- 略 -->
</ul>
接著處理在 MenuItem 上按下 Enter 或 Space 的事件,這在 <AtomicButton>
內有提到過。在這裡,MenuItem 的 <li>
元素被視為按鈕使用,因此我們得讓它的行為與按鈕一致。
我們可以在 itemsCompose
裡擴充。
const itemsCompose = computed(() => {
return props.items.map((item, index) => {
const onClick = () => {
// 略
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
};
return {
...item,
onClick,
onKeydown,
};
});
});
並將事件綁定在 MenuItem 上。
<li
v-for="item in itemsCompose"
:key="item.value"
class="atomic-dropdown__menuitem"
@click="item.onClick"
@keydown="item.onKeydown"
>
<!-- 略 -->
</li>
到這裡,我們可以用 Up Arrow、Down Arrow、Home、End 來移動焦點,並且在 MenuItem 上按下 Enter 或 Space 來觸發點擊事件。
接著處理當開啟 Menu 時,將焦點自動移動到第一個 MenuItem。
我們需要先取得 Menu 的 Element,才能找到 Menu 裡的第一個 MenuItem。
<ul
ref="menuRef"
class="atomic-dropdown"
>
<!-- 略 -->
</ul>
接著我們觀察 active
的變化,當 active
變為 true
時,我們就可以將焦點移動到第一個 MenuItem。
const menuRef = ref<HTMLElement>();
watch([active, menuRef] as const, ([isActive, menu]) => {
if (!isActive || !menu) return;
moveFocus(menu, null, nextItem);
});
最後處理當關閉 Menu 時,將焦點移回到觸發 Menu 的元素。
但這一步有個比較困難的問題,Reference 是由開發人員傳入的,我們要怎麼取得它呢?幸好我們在 <AtomicPopover>
中有類似的實作,可以重複利用這個 function。
import findFirstLegitChild from '~/helpers/findFirstLegitChild';
const referenceRef = ref<HTMLElement>();
const ReferenceComponent = defineComponent({
name: 'ReferenceComponent',
setup() {
return () => {
const child = findFirstLegitChild(slots.default?.());
if (!child) return;
return withDirectives(child, [
[
{
mounted(el: HTMLElement) {
referenceRef.value = el;
},
updated(el: HTMLElement) {
referenceRef.value = el;
},
unmounted() {
referenceRef.value = undefined;
},
},
],
]);
}
}
})
模板部分改使用 <ReferenceComponent>
。
<template>
<AtomicPopover>
<template #reference>
<ReferenceComponent />
</template>
<!-- 略 -->
</AtomicPopover>
</template>
這樣我們就可以取得 Reference 的 Element,並在關閉 Menu 時將焦點移回到 Reference 上。
watch([active, referenceRef] as const, ([isActive, reference]) => {
if (isActive || !reference) return;
reference.focus();
});
前面實作的鍵盤操作部分已經涵蓋了無障礙的一部分,但我們還可以再加入一些設定來讓元件更符合無障礙標準。
在 HTML 元素上添加 role 屬性會讓網頁對於使用輔助技術(如螢幕閱讀器)的使用者更加友善。
在這裡我們要加上 menu
、menuitem
這兩個 role。
<ul
class="atomic-dropdown"
role="menu"
>
<li
v-for="item in itemsCompose"
:key="item.value"
class="atomic-dropdown__menuitem"
role="menuitem"
>
<!-- 略 -->
</li>
</ul>
在 <AtomicPopover>
內部已經實作了 aria-controls
與 aria-expanded
,在這裡我們只需要再加上 aria-haspopup
屬性即可。
aria-haspopup
可以接受的值有:menu
、listbox
、tree
、grid
、dialog
以及 true
,其中 true
與 menu
的含義相同。
<template>
<AtomicPopover v-model="active">
<template #reference>
<component
:is="ReferenceVNode"
aria-haspopup="true"
/>
</template>
<!-- 略 -->
</AtomicPopover>
</template>
在 <AtomicDropdown>
的實作中,我們以 <AtomicPopover>
為基礎,實作了一個可以顯示彈出選單的元件。我們可以透過 items
來提供選單選項,並且還可以透過 menuitem
slot 來自定義選項內容。
鍵盤操作部分我們參考了無障礙實作指南,並應用了在 <AtomicPopover>
、<AtomicTabs>
與 <AtomicButton>
中實作與提到的概念,讓元件更加好用。也因為建立在這些元件的基礎與知識上,我們才能更有效率地完成這個元件。
<AtomicDropdown>
原始碼:AtomicDropdown.vue
<AtomicPopover>
實作回顧:[為你自己寫 Vue Component] AtomicPopover