如果要希望在不換頁的前提上塞入更多畫面無法容納的資訊,Modal 經常會是網頁設計師的首選之一。如果要從 UI 樣式上細分,又可以延伸出像是對話框(Dialog)、抽屜(Drawer)等不同變化以因應各種不同需求。因此本篇要實作的 <AtomicModal>
目標是作為其他元件的基礎元件,例如:<AtomicDialog>
與 <AtomicDrawer>
,也有些人會考慮作為 <AtomicSelect>
的基礎元件。
也因為它幾乎可以作為所有具有「modal」功能的元件的基礎元件,所以在這個元件需要考量的情境會非常多且複雜。
在元件的命名上,有很長一段時間我不太能區分 Modal
、Dialog
、Popup
以及 Lightbox
之間有什麼差異,每次遇到不同的團隊與角色都會發現他們使用的命名都不一樣,但最後在 UI 表現上卻是一樣的東西。
直到我收到了 Vue Final Modal 的作者,Hunter Liu,轉分享的一篇文章才豁然開朗。
Popups: 10 Problematic Trends and Alternatives
在這篇文章中以兩個維度來分類這些元件:
第一個維度是:使用者是否能夠與頁面其餘部分進行交互
第二個維度是:背景是否變暗
以這裡的敘述為例,我們可以整理成以下的表格:
Modal | Nonmodal | |
---|---|---|
Lightbox | Dialog, Drawer | |
Nonlightbox | FullScreen Modal, Select | Dropdown, Tooltip, Notification, Alert |
有些元件設計會讓 Select 打開時不能與背景做互動(點擊外部按鈕、連結並且禁止滾動)
當然,那些元件要放在哪一個分類中,還是要依照整體產品的需求來定義。不過我們可以得知,與其說 Modal 是一個元件,我倒覺得可以把它視為一個概念,而這個概念實作出的基礎可以被應用在很多不同的元件上。
我們盡可能地讓 <AtomicModal>
這個元件可以應用在不同的場景上,所以它的功能要盡可能地單純且必要。
因此我們只需要考慮以下介面:
modelValue
這個 props 控制開關。hideBackdrop
這個 props 決定是否隱藏背景。disableEscapePress
這個 props 決定是否禁用 ESC 鍵關閉 Modal。disableBackdropClick
這個 props 決定是否禁用點擊背景關閉 Modal。disableEscapePress
與 disableBackdropClick
在某些情境下會非常好用,例如 Modal 裡面顯示的是一個重要資訊,這可以防止使用者不小心按下 ESC 鍵或是點擊背景關閉 Modal。
除此之外我們需完成以下功能:
使用結構如下:
<template>
<AtomicButton>
開啟 Modal
</AtomicButton>
<AtomicModal v-model="modal">
<div>
內容
</div>
</AtomicModal>
</template>
首先,我們將需求中提到的功能整理成 props
與 emit
的介面,我們會需要下列屬性:
Props
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
modelValue | boolean |
控制 Modal 開關 | |
hideBackdrop | boolean |
false |
是否隱藏背景 |
disableEscapePress | boolean |
false |
是否禁用 ESC 鍵關閉 Modal |
disableBackdropClick | boolean |
false |
是否禁用點擊背景關閉 Modal |
Emits
名稱 | 參數 | 說明 |
---|---|---|
update:modelValue | boolean |
控制 Modal 開關 |
interface AtomicModalProps {
modelValue: boolean;
hideBackdrop?: boolean;
}
interface AtomicModalEmits {
(event: 'update:modelValue', value: boolean): void;
}
const props = withDefaults(defineProps<AtomicModalProps>(), {});
const emit = defineEmits<AtomicModalEmits>();
雙向綁定的部份我們沒有打算作成非受控元件,所以簡單處理雙向綁定的部分:
const modelValueWritable = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
模板的部分,因為 <AtomicModal>
的主要目的是作為像是 <AtomicDialog>
與 <AtomicDrawer>
元件的基礎元件,所以我們只需要一個 Backdrop 區塊讓使用者可以在 Modal 跟 Nonlightbox 之間切換,還有一個 default slot 讓其他的元件可以自定義需要的內容。
<template>
<Teleport to="body">
<div
v-if="modelValueWritable"
class="atomic-modal"
>
<div
v-if="!hideBackdrop"
class="atomic-modal__backdrop"
/>
<slot name="default" />
</div>
</Teleport>
</template>
.atomic-modal {
position: fixed;
inset: 0;
&__backdrop {
position: fixed;
z-index: -1;
background-color: rgba(0, 0, 0, 0.6);
inset: 0;
}
}
當使用者點擊 Backdrop 區塊時或是按下 ESC 鍵時,我們需要關閉 Modal。
首先我們先處理點擊 Backdrop 區塊時關閉 Modal,記得要實作當這兩個方法分別被禁用的判斷。
const onRootKeydown = (event: KeyboardEvent) => {
if (props.disableEscapePress) return;
if (event.key !== 'Escape') return;
event.preventDefault();
modelValueWritable.value = false;
};
const onBackdropClick = () => {
if (event.relatedTarget) return;
modelValueWritable.value = false;
};
接著將事件綁定到 Root 區塊與 Backdrop 區塊上。
<template>
<Teleport to="body">
<div
v-if="modelValueWritable"
class="atomic-modal"
@keydown="onRootKeydown"
>
<div
v-if="!hideBackdrop"
class="atomic-modal__backdrop"
@click="onBackdropClick"
/>
<slot name="default" />
</div>
</Teleport>
</template>
現在點擊事件已經可以順利進行了,但是當我們按下 ESC 鍵時,Modal 並沒有關閉!
原因很簡單,此時我們的 Root 區塊並沒有聚焦,儘管 Modal 被打開了,當前的焦點還是停留在開啟 Modal 的按鈕上。
這個問題留到 Focus Trap 完成後就可以解決。有了 Focus Trap 後,焦點會被限縮在 Modal 內部,這時在 Modal 內部按下 ESC 鍵時關閉 Modal,不論在 Modal 裡面哪個位置,事件冒泡都會將鍵盤事件傳遞到 Root 區塊上進而觸發 onRootKeydown
這個 function。
Backdrop 的部分我們選擇淡入淡出的過場效果,但是內容的部分 Dialog 比較多的可能是淡入淡出,Drawer 則有可能由上下左右滑入。因此在元件中不多處理這個部分,而是交給繼承的元件或是使用的開發人員處理。
因此我們僅針對 Backdrop 的部分做淡入淡出的處理。
.transition-fade {
&-enter-from,
&-leave-to {
opacity: 0;
}
&-enter-active,
&-leave-active {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
}
在專案中動畫的 CSS 設定,除非是很特別為某個元件設計的,通常建議放在全域的 CSS 檔案中,這樣除了 Atomic 元件可以使用,在專案其他的部分也可以共用。
<template>
<Teleport to="body">
<div
v-if="modelValueWritable"
class="atomic-modal"
>
<Transition
v-if="!hideBackdrop"
name="transition-fade"
>
<div
v-if="modelValueWritable"
class="atomic-modal__backdrop"
/>
</Transition>
<slot name="default" />
</div>
</Teleport>
</template>
但目前不會有任何過場效果,Backdrop 而是會「啪」的一下出現,「啪」的一下消失。因為 Backdrop 現在會跟著 Root 同時被渲染跟同時被移除。
我們可以使用 <Transition>
元件提供的 appear
屬性,讓元件在第一次渲染時也會有過場效果。
<template>
<Teleport to="body">
<div
v-if="modelValueWritable"
class="atomic-modal"
>
<Transition
v-if="!hideBackdrop"
appear
name="transition-fade"
>
<div
v-if="modelValueWritable"
class="atomic-modal__backdrop"
/>
</Transition>
<slot name="default" />
</div>
</Teleport>
</template>
但如果使用者在外層元件中切換 modelValue
,那麼在離場時還是會「啪」的一下消失,所以我們需要多一個狀態,modelValueLocal
,讓元件可以等到內部動畫完成後才移除 Root 區塊。
<template>
<Teleport to="body">
<div
v-if="modelValueLocal"
class="atomic-modal"
>
<Transition
v-if="!hideBackdrop"
appear
name="transition-fade"
>
<div
v-if="modelValueWritable"
class="atomic-modal__backdrop"
/>
</Transition>
<slot name="default" />
</div>
</Teleport>
</template>
具體操作流程如下:
modelValue
(modelValueWritable
) 開啟時,立即同步 modelValueLocal
開啟。modelValue
關閉,等待過場結束後才切換 modelValueLocal
關閉。const modelValueWritable = computed({
// 略
});
const modelValueLocal = ref(modelValueWritable.value);
const onAfterLeave = () => {
modelValueLocal.value = false;
};
watch(
modelValueWritable,
async value => {
if (value) return modelValueLocal.value = value;
},
{
flush: 'sync',
}
);
<template>
<Teleport to="body">
<div
v-if="modelValueLocal"
class="atomic-modal"
>
<Transition
v-if="!hideBackdrop"
appear
name="transition-fade"
@after-leave="onAfterLeave"
>
<div
v-if="modelValueWritable"
class="atomic-modal__backdrop"
/>
</Transition>
<slot name="default" />
</div>
</Teleport>
</template>
統整一下,Modal 過場顯示我們用了兩個狀態管理。
modelValueWritable
(modelValue
):外部控制 Modal 開關。modelValueLocal
:開啟 Modal 時立即同步,關閉 Modal 時等待過場結束後才切換。另外!因為現在我們的 modelValue
與 modelValueLocal
在關閉 Modal 時並不會馬上同步,為了讓未來繼承的元件能夠同步到 modelValueLocal
的狀態,我們可以透過 default slot 的方式將 modelValueLocal
傳遞給子元件。
<slot
name="default"
open="modelValueLocal"
/>
這樣我們在使用 <AtomicModal>
時就可以透過 open
這個 prop 得知 <AtomicModal>
內部的的開關狀態。
<template>
<AtomicModal v-model="modal">
<template #default="{ open }">
Modal 內部開關狀態:{{ open }}
</template>
</AtomicModal>
</template>
當我們開啟 Modal 後,我們會希望能夠禁止背景的滾動,以免操作的過程中讓開啟前的位置跑掉。
在這裡,我們的 Modal 覆蓋了整個頁面,所以我們要在開啟時鎖住 <body>
的滾動,關閉後解開鎖定。
watch(
modelValueLocal,
value => {
if (value) {
document.body.style.overflow = 'hidden';
return
}
document.body.style.overflow = '';
},
{
immediate: true,
}
);
這個做法在 Apple 使用者的瀏覽器上看起來可能會正常,但在 Windows 使用者的瀏覽器上會有 UI 跳動的問題,因為我們沒有考慮到 Scrollbar 有寬度的情況。
所以在鎖住滾動前,我們要先計算出滾動條的寬度,並將 padding-right
設定為當前 Scrollbar 的寬度;解開鎖定時,則需要將鎖的狀態還原。
watch(
modelValueLocal,
value => {
if (value) {
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = toUnit(scrollbarWidth) || '';
document.body.style.overflow = 'hidden';
return;
}
document.body.style.paddingRight = '';
document.body.style.overflow = '';
},
{
immediate: true,
}
);
這樣我們就解決了 Scrollbar 寬度造成的 UI 跳動問題。
但是!如果我們今天連續開了兩個 Modal 的話,就會有問題,我們在開啟第二個 Modal 時,會再次計算一次 Scrollbar 寬度,這時得到的寬度為 0
,這樣就會造成 padding-right
的值被覆蓋掉。
我們需要一個狀態來記錄有多少 Modal 需要鎖住 Scrollbar。最簡單的方法我們可以用一個全域的變數來記錄。
// 全域變數
let lockCount = 0;
watch(
modelValueLocal,
value => {
if (value) {
if (!lockCount) {
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = toUnit(scrollbarWidth) || '';
document.body.style.overflow = 'hidden';
}
lockCount += 1;
return;
}
if (lockCount > 0) lockCount -= 1;
if (!lockCount) {
document.body.style.paddingRight = '';
document.body.style.overflow = '';
}
},
{
immediate: true,
}
);
記得預防 Modal 被直接銷毀,我們需要在 onUnmounted
時將 lockCount
減一確認。
onUnmounted(() => {
if (lockCount > 0) lockCount -= 1;
if (!lockCount) {
document.body.style.paddingRight = '';
document.body.style.overflow = '';
}
});
這樣我們開第二個 Modal 時就不會對 <body>
重新上鎖,同時當其中一個 Modal 關閉時,也因為還有 Modal 需要鎖住 <body>
,所以不會解開鎖定。
當 Modal 開啟時,我們希望焦點只能在 Modal 內部移動,這樣使用者才不會一不小心操作到 Modal 以外的功能,這種把焦點可移動的範圍限制在一個區域內的概念稱之為 Focus Trap。
簡單敘述一下 Focus Trap 可以怎麼做:
概念大致如上,那我們要怎麼找到 Modal 內所有可聚焦的元素呢?
以下是一個叫 tabbable
的工具,原始碼中找尋所有可聚焦元素的選擇器。
const candidateSelectors = [
'input:not([inert])',
'select:not([inert])',
'textarea:not([inert])',
'a[href]:not([inert])',
'button:not([inert])',
'[tabindex]:not(slot):not([inert])',
'audio[controls]:not([inert])',
'video[controls]:not([inert])',
'[contenteditable]:not([contenteditable="false"]):not([inert])',
'details>summary:first-of-type:not([inert])',
'details:not([inert])',
];
剩下的,我相信只要挽起袖子,一個情境一個情境地解決,過陣子就會有一個完整的 Focus Trap 功能了!
為了快速我們可以借助一下另外一個工具:focus-trap
。
focus-trap
可以幫助我們將焦點鎖定在選定的範圍內,並且當新的 Focus Trap 啟動時,預設會暫停舊的 Focus Trap,而在新的 Focus Trap 關閉後,舊的 Focus Trap 會恢復到啟用的狀態。
所以我們只要關注何時啟動 Focus Trap 與何時關閉 Focus Trap 就好了。
首先我們先要取得 Root 區塊的 DOM 元素,這樣我們才能將焦點鎖定在這個區塊內。
<template>
<Teleport to="body">
<div
v-if="modelValueLocal"
ref="rootRef"
class="atomic-modal"
>
<!-- 略 -->
</div>
</Teleport>
</template>
接著我們在 rootRef
更新時,初始化並啟用 Focus Trap。
const rootRef = ref<HTMLElement>();
let trap: FocusTrap | undefined;
watch(rootRef, (element) => {
if (!element) return
trap = createFocusTrap(element);
trap.activate();
})
停用的時機有兩個,分別為 modelValueWritable
與 modelValueLocal
關閉時。
使用者有可能同時開啟多個 Modal,如果在 modelValueLocal
關閉後才停用,讓舊的 Focus Trap 重新啟動,就可能會發生一些比較心急的使用者頻繁地按下 ESC 鍵,但卻沒有關閉 Modal 的情況。
所以一旦確定元件要離開,越早停用 Focus Trap 越好,也因此我們選擇在 modelValueLocal
關閉時停用。
watch(
modelValueWritable,
async value => {
if (value) {
modelValueLocal.value = value
return
};
trap?.deactivate();
trap = undefined;
},
{
flush: 'sync',
}
);
另外一樣要記得在元件被銷毀時,也要停用 Focus Trap。
onUnmounted(() => {
// 略
trap?.deactivate();
trap = undefined;
});
到這裡我們就完成了 Focus Trap 的功能。
不過 focus-trap
這個工具本身有一個限制,在指定要啟動 Focus Trap 的區塊時,區塊內一定要有可一個是可以透過 Tab 聚焦的元素,否則 Focus Trap 會拋出錯誤警告。
Error: Your focus-trap must have at least one container with at least one tabbable node in it at all times
要解決這個問題我們可以嘗試確保在 Modal 裡面至少有一個可以透過 Tab 聚焦的元素
<template>
<Teleport
v-if="modelValueLocal"
to="body"
>
<div
ref="rootRef"
class="atomic-modal"
@keydown="onRootKeydown"
>
<!-- 略 -->
<slot name="default" />
<div tabindex="0" />
</div>
</Teleport>
</template>
這樣 Modal 開啟就保底會有一個可以透過 Tab 聚焦的元素,這樣就不會有 Focus Trap 啟動時拋出錯誤警告的問題。
但這個保底用的元素又衍生了一個問題,當我們按下 Tab 鍵時,焦點會從 Modal 內最後一個可聚焦的元素跳到這個保底元素,這樣會讓使用者感到困惑。
要解決這個問題我們可以讓焦點移動到這個保底元素上時強制移動到可聚焦元素的第一個。
let focusableElements: FocusableElement[] = [];
const onSentinelFocus = () => {
if (focusableElements.length === 1) return;
focusableElements[0]?.focus();
};
watch(rootRef, element => {
if (!element) return;
focusableElements = tabbable(element);
if (!focusableElements.length) return;
trap = createFocusTrap(element);
trap.activate();
});
<div
ref="rootRef"
class="atomic-modal"
@keydown="onRootKeydown"
>
<!-- 略 -->
<slot name="default" />
<div
tabindex="0"
@focus="onSentinelFocus"
/>
</div>
這樣 Focus Trap 就大致完成,焦點會在我們預期的內容內移動,沒有任何元素可以聚焦時也至少還有一個保底元素可以被聚焦。
在實際開發應用中我們可能會在 <AtomicModal>
加入 <AtomicDropdown>
、<AtomicTooltip>
等其他元件。第一個要面臨的問題是 Focus Trap 現在只能在 <AtomicModal>
內部移動,這時如果我們點開 <AtomicDropdown>
的 Dropdown,這個 Dropdown 不在 Focus Trap 的範圍內,我們將無法對 Dropdown 進行任何操作;另外一個問題,我們也有在 <AtomicDropdown>
點擊 Dropdown 以外的地方關閉 Dropdown。但在 <AtomicModal>
內的 <AtomicDropdown>
開啟時是點擊到 Modal 的 Backdrop,會連同 Dropdown 跟 Modal 一起被關閉,這樣的使用體驗不是很好。
要解決第一個問題,我們可以在 <AtomicDropdown>
底層元件 <AtomicPopover>
中也加入 Focus Trap 的功能,這樣當我們開啟 <AtomicDropdown>
時,Focus Trap 的範圍就會變成 <AtomicDropdown>
的範圍。
let trap: FocusTrap | undefined;
watch(popoverRef, popover => {
if (!popover) {
trap?.deactivate();
trap = undefined;
return;
}
if (!tabbable(popover).length) return;
trap = createFocusTrap(popover);
trap.activate();
});
這樣我們就可以確保當 <AtomicDropdown>
開啟時,Focus Trap 的範圍就變成 <AtomicDropdown>
的 Menu。不過我們又會發現,<AtomicDropdown>
點擊外部關閉 Menu 的功能也會失效,這是因為 Focus Trap 限制了點擊外部事件的觸發,好在 focus-trap
有一些選項可以讓我們調整這個行為。
trap = createFocusTrap(popover, {
clickOutsideDeactivates: true,
})
因為原本的期望是我們點擊了 <AtomicDropdown>
外部就關閉 Menu,所以我們將 clickOutsideDeactivates
設定為 true
,這樣當我們點擊了 <AtomicDropdown>
外部時,Focus Trap 就會自動停用,如此一來我們就可以順利地關閉 Menu 了。
在 Modal 與 Dropdown 同時開啟時點擊 Backdrop 會同時關閉 Dropdown 跟 Modal。
我們希望點擊 Backdrop 時只會關閉 Dropdown,第二次才會關閉 Modal。
在前面我們有統整了四個象限的分類,這些彈出覆蓋在畫面上的元件我們統稱為 Popups。有了 Popups 的資料管理中心,我們可以紀錄當前開啟的 Popups 有哪些,也可以找到最新開啟的 Popup 是哪一個。
我們用 Vue Plugin 的方法來實作 Popups 資料管理中心。
import { inject, shallowReactive } from 'vue'
import type { ComponentInternalInstance, InjectionKey, ObjectPlugin } from 'vue'
type Popup = ComponentInternalInstance
const POPUPS_MANAGEMENT: InjectionKey<PopupsManager> = Symbol()
export function createPopupsManager () {
const overlays = shallowReactive<Popup[]>([])
const add = (popup: Popup) => {}
const remove = (popup: Popup) => {}
const isTop = (popup: Popup) => {}
const manager: PopupsManager = {
add,
remove,
isTop,
}
return <ObjectPlugin>{
install(app) {
app.provide(POPUPS_MANAGEMENT, manager)
}
}
}
我們需要實作三個方法:add
、remove
、isTop
。
add 新增 Popop
當 Popup 開啟時,我們將其推入 Popups 資料管理中心,如果重複加入則需要排除。
const add = (popup: Popup) => {
let index = popups.indexOf(popup)
if (index !== -1) return index
index = popups.length
popups.push(popup)
return index
}
remove 刪除 Popop
當 Popup 關閉時,我們將其從 Popups 資料管理中心移除。
const remove = (popup: Popup) => {
const index = indexOf(popups, popup)
if (index === -1) return index;
popups.splice(index, 1)
return index
}
isTop 確認 Popup 是否在最上層
確認傳入 Popup 的是否為最新開啟的 Popup。
const isTop = (popup: Popup) => {
return popups.length > 0 && popups[popups.length - 1] === popup
}
接著我們將 Popups 資料管理中心安裝到 Vue App 中。
const popups = createPopupsManager()
const app = createApp(App)
app.use(popups)
app.mount('#app')
現在我們有一個全域的 Popups 資料管理中心,我們可以透過 Popups 資料管理中心來管理所有 Popups 的開啟與關閉。
不過可以的話,我們也提供一個 Composables API 讓元件可以更方便地取用 Popups 資料管理中心。
export function usePopupsManager () {
const popups = inject(POPUPS_MANAGEMENT, null)
if (!context) throw new Error('error')
return popups
}
接著我們就要在 <AtomicModal>
與 <AtomicPopover>
中使用 Popups 資料管理中心,並在適當的時機新增刪除 Popup。
AtomicModal
const instance = getCurrentInstance()!;
const popups = usePopupsManager();
watch(modelValueLocal, value => {
if (value) {
popups.add(instance);
return;
}
});
watch(
modelValueWritable,
async value => {
if (value) return;
popups.remove(instance);
},
{
flush: 'sync',
}
);
AtomicPopover
const instance = getCurrentInstance()!;
const popups = usePopupsManager();
watch(
modelValueWritable,
value => {
!value ? popups.remove(instance) : popups.add(instance);
},
{
immediate: true,
flush: 'sync',
}
);
這樣不論是基於 <AtomicModal>
還是 <AtomicPopover>
的 Popups,在開啟時都會被推進 Popups 資料管理中心,並在關閉時移除。
這樣我們可以在點擊 Backdrop 時檢查 Popups 資料管理中心,如果最新開啟的 Popup 是自己,就執行關閉,如果不是,那可能開啟的是其他 Popups,不執行關閉的動作。
const onBackdropClick = () => {
if (props.disableBackdropClick) return;
if (!popups.isTop(instance)) return;
modelValueWritable.value = false;
};
這樣我們再看看 Modal 與 Dropdown 同時開啟時的情況。
這次我們花了很大的篇幅在製作 <AtomicModal>
,元件本身架構非常單純,但是在實作上卻有很多細節需要注意,例如 Transition、Scroll Lock、Focus Trap 等等。
在技術執行上,繁雜的 Scroll Lock 或 Focus Trap,我們只要把所有情境一個一個列舉並且逐一解決,除了耗時外都不算太難達成,有時程壓力或是擔心列舉的情境不夠全面的話,可以試著找找看有沒有工具專注在解決這些「計算」問題,像是 Scroll Lock 如果想更詳盡地覆蓋各種 edge case,可以考慮使用 body-scroll-lock
或是 scroll-lock
等 npm 套件。
除了元件本身外,我們還新增了 Popups 資料管理中心,讓我們跨多個不同元件的整合可以更加容易。除了有提到的「開啟狀態」管理外,Scroll Lock 也可以收進 Popup 管理中心管理,這樣可以讓元件邏輯更加清晰,也可以讓更多元件更容易地共享這些狀態。
<AtomicModal>
原始碼:AtomicModal.vue