對話框(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 與簡單的功能實現就好了。
<AtomicTable>
原始碼:AtomicTable.vue
<AtomicModal>
實作回顧:[為你自己寫 Vue Component] AtomicModal