iT邦幫忙

2024 iThome 鐵人賽

DAY 19
2
Modern Web

為你自己寫 Vue Component系列 第 19

[為你自己寫 Vue Component] AtomicDialog

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicDialog

對話框(Dialog)是一種常見的網頁元件,通常用來顯示重要的資訊或要求使用者進行某些操作。Dialog 在顯示時會暫停其他頁面內容的操作,強制使用者集中注意力於 Dialog 的內容。Dialog 經常應用於以下情況:

  • 操作確認:例如刪除文件、提交表單等操作,使用 Dialog 來確認使用者的意圖,避免誤操作。
  • 資訊提示:例如顯示錯誤訊息、成功訊息或其他需要立即告知使用者的資訊。
  • 表單輸入:例如登入或註冊表單,使用 Dialog 來集中處理使用者輸入資訊。
  • 選擇選項:例如設定選項、偏好設定等,使用 Dialog 來讓使用者選擇或修改設定。

由於 Dialog 是一個用來與使用者進行重要互動的元件,它會暫時打斷使用者的當前操作,讓使用者專注於需要立即處理的事項。因此,Dialog 的使用需要特別謹慎,盡可能不要過度使用 Dialog,這會打斷使用者的操作流程,造成使用者體驗的惡化。

元件分析

元件架構

AtomicDialog 元件架構

  1. Backdrop:背景區塊,當 Dialog 打開時,背景會變暗,並且使用者無法與背景互動。
  2. Header:Dialog 的 header 區塊,這裡會放置 Dialog 的標題或是其他功能按鈕,例如關閉按鈕。
  3. Content:Dialog 的主要區塊,這裡會放置 Dialog 的內容。

功能設計

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

Element Plus

Element Plus Dialog

<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> 元件內部結構包含了三個區塊:defaultheaderfooterfooter 在沒有使用時不會渲染出來。

Props 部分,Element Plus 提供了 width 來設定 Dialog 的寬度,fullscreen 設定是否顯示全螢幕模式。

特別的是 Element Plus 預設的 Dialog 不是出現在畫面的正中間,而是距離螢幕上緣 15vh,這個位置相對於正中間來說更為舒適。有興趣的話可以透過開發者工具來查看是如何設計的。

Vuetify

Vuetify Dialog

<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,activatordefaultactivator 是用來觸發 Dialog 的元素,default 則是 Dialog 的內容,這個做法跟我們在 <AtomicPopover> 的時候有點類似。

另外一個比較特別的設計是 Vuetify 的 <VDialog> 並不具有 Container 區塊的結構,取而代之的是使用 <VCard> 相關元件來拼湊出 Dialog 的內容。這樣的好處是可以讓 Dialog 的內容更為彈性,例如需要開啟一張行銷 Banner 時,可以直接使用 <VImg> 作為內容顯示。整體來看,Vuetify 的 <VDialog> 更像是一個容器,用途更接近於我們前面做的 <AtomicModal>

此外,因為遵循 Material Design 的設計,Vuetify 的 Dialog 一般是不自帶 Close 按鈕的,僅在全螢幕模式下會有 Close 按鈕。

PrimeVue

PrimeVue Dialog

<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 出現在畫面中的定位設定,例如:leftrighttop-lefttoptop-rightbottom-leftbottombottom-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>

元件實作

首先,我們將需求中提到的功能整理成 propsemit 的介面,我們會需要下列屬性:

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);
  }

  // 其他略過
}

Atomic Dialog Basic

進階功能

可拖曳的 Dialog

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

可拖曳的 Dialog

在 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;
    }
  }
}

我們先用一個比較顯眼的顏色來標示它的存在。

Atomic Dialog Draggable

當滑鼠在 Sensor 區塊按下時,我們可以開始監聽滑鼠的移動事件,並根據滑鼠的移動距離來調整 Dialog 的位置。

<div
  class="atomic-dialog__sensor atomic-dialog__sensor--top"
  @mousedown="onSensorMousedown"
/>

滑鼠移動距離的計算方式如下:

  1. 記錄滑鼠按下去時的位置。
  2. 每次滑鼠移動時,計算滑鼠的移動距離。

為了計算滑鼠按下去後的移動距離,我們需要一個記錄滑鼠當下座標的功能,可以建立一個 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

我們需要使用 role="dialog" 來告訴使用者這個 Dialog 是一個對話框,這樣使用者的輔助技術才能正確地辨識這個元件。

<div
  class="atomic-dialog__container"
  role="dialog"
  tabindex="-1"
>
  <div class="atomic-dialog__wrapper">
    <!-- Content(Dialog 內容)-->
  </div>
</div>

ARIA 屬性

在 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 與基本功能,像是 titlewidthfullscreentransition 等等的設定。

在進階功能中,我們實現了 Dialog 的拖曳功能,這在 Element Plus 與 PrimeVue 中都有提供。這個功能可以讓使用者自由調整 Dialog 的位置。拖曳功能本身並不困難,但為了實現拖曳功能,我們需要稍微調整一些 HTML 結構,確保這些功能之間不會互相干擾。

好在 <AtomicModal> 已經先完成了很多功能,我們只需要專注在 Dialog 的 UI 與簡單的功能實現就好了。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicModal
下一篇
[為你自己寫 Vue Component] AtomicToast
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言