![[為你自己寫 Vue Component] AtomicDialog](https://ithelp.ithome.com.tw/upload/images/20240924/20120484DUsl1u5ea7.png)
對話框(Dialog)是一種常見的網頁元件,通常用來顯示重要的資訊或要求使用者進行某些操作。Dialog 在顯示時會暫停其他頁面內容的操作,強制使用者集中注意力於 Dialog 的內容。Dialog 經常應用於以下情況:
由於 Dialog 是一個用來與使用者進行重要互動的元件,它會暫時打斷使用者的當前操作,讓使用者專注於需要立即處理的事項。因此,Dialog 的使用需要特別謹慎,盡可能不要過度使用 Dialog,這會打斷使用者的操作流程,造成使用者體驗的惡化。

在開始實作前,我們先研究各個 UI Library 的 Dialog 元件是如何設計的。
Element Plus

<template>
<ElDialog
v-model="dialogVisible"
width="500"
>
<template #header>
Tips
</template>
<span>This is a message</span>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">Cancel</ElButton>
<ElButton type="primary" @click="dialogVisible = false">
Confirm
</ElButton>
</div>
</template>
</ElDialog>
</template>
Element Plus 的 <ElDialog> 使用方式是透過 v-model 來控制 Dialog 的開啟與關閉,點擊 Backdrop 與按下 ESC 鍵都可以關閉 Dialog。
結構部分 <ElDialog> 元件內部結構包含了三個區塊:default、header 與 footer。footer 在沒有使用時不會渲染出來。
Props 部分,Element Plus 提供了 width 來設定 Dialog 的寬度,fullscreen 設定是否顯示全螢幕模式。
特別的是 Element Plus 預設的 Dialog 不是出現在畫面的正中間,而是距離螢幕上緣 15vh,這個位置相對於正中間來說更為舒適。有興趣的話可以透過開發者工具來查看是如何設計的。
Vuetify

<template>
<VDialog max-width="500">
<template #activator="{ props: activatorProps }">
<VBtn
v-bind="activatorProps"
color="surface-variant"
text="Open Dialog"
variant="flat"
/>
</template>
<template #default="{ isActive }">
<VCard title="Dialog">
<!-- 略 -->
<VCardAction>
<VSpacer />
<VBtn
text="Close Dialog"
@click="isActive.value = false"
/>
</VCardAction>
</VCard>
</template>
</VDialog>
</template>
Vuetify 除了有比較常見的使用方式外,也提供了範例中這種比較特別的使用方式,這種方式使用了兩個 slots,activator 與 default。activator 是用來觸發 Dialog 的元素,default 則是 Dialog 的內容,這個做法跟我們在 <AtomicPopover> 的時候有點類似。
另外一個比較特別的設計是 Vuetify 的 <VDialog> 並不具有 Container 區塊的結構,取而代之的是使用 <VCard> 相關元件來拼湊出 Dialog 的內容。這樣的好處是可以讓 Dialog 的內容更為彈性,例如需要開啟一張行銷 Banner 時,可以直接使用 <VImg> 作為內容顯示。整體來看,Vuetify 的 <VDialog> 更像是一個容器,用途更接近於我們前面做的 <AtomicModal>。
此外,因為遵循 Material Design 的設計,Vuetify 的 Dialog 一般是不自帶 Close 按鈕的,僅在全螢幕模式下會有 Close 按鈕。
PrimeVue

<Dialog
v-model:visible="visible"
modal
header="Header"
:style="{ width: '50rem' }"
>
<p class="mb-8">
<!-- 略 -->
</p>
</Dialog>
PrimeVue 的 Dialog 雙向綁定的屬性不是預設的 modelValue,而是選用了 visible;PrimeVue 預設不會開啟 Backdrop,開發人員需要的話可以透過 modal 來開啟;PrimeVue 最接近全螢幕模式的設定是 maximizable,不過這不會讓我們開啟的 Dialog 變成全螢幕,而是在 Header 區塊多了一個全螢幕的按鈕供使用者切換。
另外,PrimeVue 提供了多種 Dialog 出現在畫面中的定位設定,例如:left、right、top-left、top、top-right、bottom-left、bottom、bottom-right 等等,不同的定位方式會有不同的滑入方向。
整體比較下來,各個 UI Library 的 Dialog UI 呈現方式都大同小異,除了基本的開關功能外,都還會配上 fullscreen 的設定。結構部分,Vuetify 的作法是開發人員直接整合 <VCard> 元件,使用彈性極大。除非是希望做到非常通用,不然我們其實可以至少給一些基本的結構。
綜合以上並結合自身經驗,我們統整出 <AtomicDialog> 的功能:
modelValue 設定 Dialog 的開啟與關閉。title 設定 Dialog 的標題。width 設定 Dialog 的寬度。fullscreen 設定 Dialog 是否全螢幕。transition 設定 Dialog 進退場的動畫。<AtomicModal> 的功能,可以透過 disableBackdropClick 設定是否點擊 Backdrop 後關閉 Dialog。<AtomicModal> 的功能,可以透過 disableEscapePress 設定是否按下 ESC 鍵後關閉 Dialog。使用結構如下:
<template>
<div class="space-x-2">
<AtomicButton @click="onButtonClick">
Open
</AtomicButton>
</div>
<AtomicDialog
v-model="open"
title="如夢令"
transition="slide-up"
>
<p>
昨夜雨疏風驟,濃睡不消殘酒。試問卷簾人,卻道海棠依舊。知否,知否,應是綠肥紅瘦。
</p>
</AtomicDialog>
</template>
首先,我們將需求中提到的功能整理成 props 與 emit 的介面,我們會需要下列屬性:
Props
| 名稱 | 型別 | 預設值 | 說明 |
|---|---|---|---|
| modelValue | boolean |
控制 Dialog 開關 | |
| title | string |
Dialog 標題 | |
| width | string, number |
640 |
Dialog 寬度 |
| fullscreen | boolean |
false |
Dialog 是否全螢幕 |
| transition | fade, slide-down, slide-left, slide-up, slide-right |
fade |
Dialog 進退場的動畫 |
| disableBackdropClick | boolean |
false |
是否點擊 Backdrop 後關閉 Dialog |
| disableEscapePress | boolean |
false |
是否按下 ESC 鍵後關閉 Dialog |
Emits
| 名稱 | 參數 | 說明 |
|---|---|---|
| update:modelValue | boolean |
控制 Modal 開關 |
interface AtomicDialogProps {
modelValue: boolean;
title?: string;
transition?:
| 'fade'
| 'slide-up'
| 'slide-down'
| 'slide-right'
| 'slide-left';
width?: number | string;
fullscreen?: boolean;
disableEscapePress?: boolean;
disableBackdropClick?: boolean;
}
interface AtomicDialogEmits {
(event: 'update:modelValue', value: boolean): void;
}
const props = withDefaults(defineProps<AtomicDialogProps>(), {
title: undefined,
transition: 'fade',
width: 640,
});
雙向綁定的部分我們一樣沒有打算作成非受控元件,所以簡單處理雙向綁定的部分:
const modelValueWritable = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
模板部分,我們直接繼承 <AtomicModal> 的實作。在 <AtomicModal> 內部已經有 Backdrop 區塊,並且實作了許多可共用的功能,所以我們只要專心處理 Dialog 的過場動畫與內容即可:
<template>
<AtomicModal
v-model="modelValueWritable"
class="atomic-dialog"
:class="{
'atomic-dialog--fullscreen': fullscreen,
}"
:disable-backdrop-click="disableBackdropClick"
:disable-escape-press="disableEscapePress"
:style="{
'--dialog-width': toUnit(width),
}"
>
<template #={ open }>
<Transition
appear
:name="`transition-${transition}`"
>
<div
class="atomic-dialog__container"
role="dialog"
tabindex="-1"
>
<!-- Content(Dialog 內容)-->
</div>
</Transition>
</template>
</AtomicModal>
</template>
除了在 <AtomicModal> 裡有實作的 fade 外,這裡再提供一個簡單的 slide-up 過場動畫,其他的過場動畫可以如法炮製,自行擴充:
.transition-slide-up {
&-enter-from,
&-leave-to {
transform: translateY(100vh);
}
&-enter-active,
&-leave-active {
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
}
<AtomicDialog> 內的結構我們參考 Material Design 的設計,但這裡完全可以依照專案需求做調整。
<div
v-if="title || fullscreen"
class="atomic-dialog__header"
>
<AtomicButton
v-if="fullscreen"
aria-label="close"
color="info"
shape="square"
variant="text"
@click="modelValueWritable = false"
>
<CloseSvg
height="20"
width="20"
/>
</AtomicButton>
<h2 class="atomic-dialog__title">
<slot name="title">
{{ title }}
</slot>
</h2>
</div>
<div class="atomic-dialog__content">
<slot name="default" />
</div>
加上樣式設定後,我們就完成了 <AtomicDialog> 的基本實作。
.atomic-dialog {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
&__container {
background-color: white;
box-shadow: 0 0 20px rgba(black, 0.1);
overflow: auto;
margin: 1.5rem;
width: var(--dialog-width);
max-width: 100%;
max-height: 100%;
max-height: calc(100% - 3rem);
border-radius: 0.5rem;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
// 其他略過
}

在 Element Plus 與 PrimeVue 中,Dialog 都有提供拖曳的功能,這個功能可以讓使用者自由調整 Dialog 的位置,如果專案有需要,我們也可以將這個功能加入到 <AtomicDialog> 中。

在 Element Plus 與 PrimeVue 中,拖曳功能的感應範圍都在 Dialog 內的 Header 區塊,這意味著如果要開啟拖曳功能,Header 區塊就必須存在。
為了讓我們未來在使用時有不渲染 Header 區塊的彈性,我們可以考慮將拖曳功能的感應區塊與整個 Dialog 的 Padding 重疊。
因此如果啟用了拖曳功能,我們可以在 Dialog 四周空白部分分別加上一個 Sensor(感應區塊)。
<div class="atomic-dialog__container">
<!-- Content(Dialog 內容)-->
<template v-if="draggable && !fullscreen">
<div class="atomic-dialog__sensor atomic-dialog__sensor--top" />
<div class="atomic-dialog__sensor atomic-dialog__sensor--right" />
<div class="atomic-dialog__sensor atomic-dialog__sensor--bottom" />
<div class="atomic-dialog__sensor atomic-dialog__sensor--left" />
</template>
</div>
記得,當全螢幕模式時,我們不需要拖曳功能,因為 Dialog 已經佔據了整個畫面。
.atomic-dialog {
&__sensor {
position: absolute;
cursor: move;
&--top {
top: 0;
right: 0;
left: 0;
height: 16px;
}
&--right {
top: 0;
right: 0;
bottom: 0;
width: 24px;
}
}
}
我們先用一個比較顯眼的顏色來標示它的存在。

當滑鼠在 Sensor 區塊按下時,我們可以開始監聽滑鼠的移動事件,並根據滑鼠的移動距離來調整 Dialog 的位置。
<div
class="atomic-dialog__sensor atomic-dialog__sensor--top"
@mousedown="onSensorMousedown"
/>
滑鼠移動距離的計算方式如下:
為了計算滑鼠按下去後的移動距離,我們需要一個記錄滑鼠當下座標的功能,可以建立一個 useMouse 的 Composable API 來告訴我們此時此刻滑鼠的位置。
useMouse.ts
import { onMounted, onUnmounted, ref } from 'vue';
export default function useMouse() {
const x = ref(0);
const y = ref(0);
const onMousemove = (event: MouseEvent) => {
x.value = event.x;
y.value = event.y;
};
onMounted(() => {
window.addEventListener('mousemove', onMousemove);
});
onUnmounted(() => {
window.removeEventListener('mousemove', onMousemove);
});
return {
x,
y,
};
}
我們拿到了一個即時紀錄滑鼠位置的功能,接下來我們可以在 <AtomicDialog> 中使用這個功能。
第一步:紀錄滑鼠在 Sensor 區塊按下時的起始位置。
const { x, y } = useMouse();
const start = ref<Coords | null>(null);
const onSensorMousedown = () => {
start.value = {
x: x.value,
y: y.value,
};
};
第二步:按著滑鼠不放,每當移動時計算出移動的距離,我們把移動距離的紀錄存到 translate 裡面。
const translate = ref<Coords>({ x: 0, y: 0 });
const onDragStart = () => {
start.value = {
x: x.value,
y: y.value,
};
window.addEventListener('mousemove', onWindowMousemove);
};
const onWindowMousemove = () => {
if (!start.value) return;
translate.value.x = x.value - start.value.x;
translate.value.y = y.value - start.value.y;
};
第三步:放開滑鼠後清除起始位置紀錄,移除監聽事件。
const onDragStart = () => {
// 略
window.addEventListener('mousemove', onWindowMousemove);
window.addEventListener('mouseup', onWindowMouseup);
};
const onWindowMouseup = () => {
start.value = null;
window.removeEventListener('mousemove', onWindowMousemove);
window.removeEventListener('mouseup', onWindowMouseup);
};
最後我們還需要做一個校正的功能,當我們移動後放開滑鼠再重新按下滑鼠時,我們的 start 會被記錄為這次點下的起始位置,translate 算出來的也會是這次移動的距離,這樣會造成 Dialog 的位置跳動。
要解決這個問題,我們只需要在重新按下滑鼠時,將 start 減去上次移動的距離就可以了。
const onDragStart = () => {
start.value = {
x: x.value - translate.value.x,
y: y.value - translate.value.y,
};
// 略
};
最後,我們只要把 translate 的值轉換成 style 套用到 Container 上就完成了。
<div
class="atomic-dialog__container"
:style="{
transform: `translate(${translate.x}px, ${translate.y}px)`,
}"
>
<!-- Content(Dialog 內容)-->
<template v-if="draggable && !fullscreen">
<!-- 拖曳感應元素 -->
</template>
</div>
現在我們的 Dialog 可以自由拖曳了。
但拖曳後的 Dialog 在關閉時的過場動畫會有問題,因為 style 上的 translate 會覆蓋掉關閉時退場動畫的 translate,我們不能在 Container 上實作這個功能。
我們可以在 Container 跟 Content 之間再加一層 Wrapper,這樣我們就可以在 Wrapper 上做拖曳的功能,而 Container 仍然可以做過場動畫。
原本的結構:
<Transition>
<div
v-if="open"
class="atomic-dialog__container"
:style="{
transform: `translate(${translate.x}px, ${translate.y}px)`,
}"
>
<!-- Content(Dialog 內容)-->
<template v-if="draggable && !fullscreen">
<!-- 拖曳感應元素 -->
</template>
</div>
</Transition>
調整後的結構:
<Transition>
<div class="atomic-dialog__container">
<div
class="atomic-dialog__wrapper"
:style="{
transform: `translate(${translate.x}px, ${translate.y}px)`,
}"
>
<!-- Content(Dialog 內容)-->
<template v-if="draggable && !fullscreen">
<!-- 拖曳感應元素 -->
</template>
</div>
</div>
</Transition>
CSS 部分也得隨調整:
.atomic-dialog {
&__container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
&__wrapper {
background-color: white;
box-shadow: 0 0 20px rgba(black, 0.1);
overflow: auto;
margin: 1.5rem;
width: var(--dialog-width);
max-width: 100%;
max-height: 100%;
max-height: calc(100% - 3rem);
border-radius: 0.5rem;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
// 其他略過
}
現在 Container 可以正常執行進退場動畫,但也因為 Container 填滿了整個螢幕,原本的 Backdrop 會被遮蓋,我們無法利用 Backdrop 來關閉 Dialog。
我們必須在 Container 上實作點擊 Backdrop 關閉 Dialog 的功能。
<div
class="atomic-dialog__container"
@click="onContainerClick"
>
<div class="atomic-dialog__wrapper">
<!-- Content(Dialog 內容)-->
</div>
</div>
const onContainerClick = (event: MouseEvent) => {
if (props.disableBackdropClick) return;
if (event.target !== event.currentTarget) return;
onClose();
};
這樣做當然可以實現點擊 Backdrop 關閉 Dialog,但因為 Wrapper 是 Container 的子層,如果使用者在使用滑鼠選取文字時滑鼠不小心移到 Backdrop 上才放開,就會關閉 Dialog,這樣的體驗是不好的。
我們可以考慮在 Container 上監聽 mousedown 事件,並當使用者按下滑鼠時,紀錄當下的 DOM 元素是否是事件綁定的 Container,如果是,我們再去判斷是否要關閉 Dialog。
<div
class="atomic-dialog__container"
@click="onContainerClick"
@mousedown="onContainerMousedown"
>
<div class="atomic-dialog__wrapper">
<!-- Content(Dialog 內容)-->
</div>
</div>
let backdropClick = false;
const onContainerClick = () => {
if (props.disableBackdropClick) return;
if (!backdropClick) return;
onClose();
};
const onContainerMousedown = (event: MouseEvent) => {
backdropClick = event.target === event.currentTarget;
}
這樣我們不但完成了拖曳功能,也解決了拖曳功能衍生的點擊 Backdrop 關閉 Dialog 的問題。不過這時候是不是應該要說:「直接點擊 Container 關閉 Dialog」呢?
我們需要使用 role="dialog" 來告訴使用者這個 Dialog 是一個對話框,這樣使用者的輔助技術才能正確地辨識這個元件。
<div
class="atomic-dialog__container"
role="dialog"
tabindex="-1"
>
<div class="atomic-dialog__wrapper">
<!-- Content(Dialog 內容)-->
</div>
</div>
在 Dialog 中,我們可以使用下列 ARIA 屬性來增加使用者的體驗:
aria-labelledby="IDREF":指定 Dialog 的標題。aria-describedby="IDREF":指定 Dialog 的內容。aria-modal="true":告訴使用者這個 Dialog 是一個 Modal,使用者只能在 Dialog 上進行操作。const id = `dialog-${Math.round(Math.random() * 1e5)}`;
<div
:aria-describedby="title || fullscreen ? `${id}-content` : undefined"
:aria-labelledby="`${id}-title`"
aria-modal="true"
class="atomic-dialog__container"
role="dialog"
tabindex="-1"
>
<div class="atomic-dialog__wrapper">
<!-- Content(Dialog 內容)-->
</div>
</div>
在 <AtomicDialog> 的實作中,我們繼承了 <AtomicModal> 的功能,並且實現了 Dialog 的 UI 與基本功能,像是 title、width、fullscreen、transition 等等的設定。
在進階功能中,我們實現了 Dialog 的拖曳功能,這在 Element Plus 與 PrimeVue 中都有提供。這個功能可以讓使用者自由調整 Dialog 的位置。拖曳功能本身並不困難,但為了實現拖曳功能,我們需要稍微調整一些 HTML 結構,確保這些功能之間不會互相干擾。
好在 <AtomicModal> 已經先完成了很多功能,我們只需要專注在 Dialog 的 UI 與簡單的功能實現就好了。
<AtomicDialog> 原始碼:AtomicDialog.vue
<AtomicModal> 實作回顧:[為你自己寫 Vue Component] AtomicModal