iT邦幫忙

2024 iThome 鐵人賽

DAY 20
2
Modern Web

為你自己寫 Vue Component系列 第 20

[為你自己寫 Vue Component] AtomicToast

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicToast

Toast 是一個提供使用者操作回饋的元件,通常會在幾秒鐘後自動消失。這類元件可以用來通知使用者新增或修改資料的成功或失敗,或者刪除訊息的成功或失敗。有時候還會提供像是 UNDO 按鈕,讓使用者可以撤銷前一個動作。

元件分析

元件命名

在各個 UI Library 中,Toast 的名稱可能有所不同。例如,在 Nuxt UI 中,這個元件被稱為 Notification;在 Element Plus 中,有 Notification 和 Message 元件;而在 PrimeVue 中則一樣稱為 Toast。Vuetify 因為遵循 Material Design 規範,將其命名為 Snackbar。

Toast 這個名稱源自其顯示方式和 UI 表現上的特徵。日常生活中的 toast 指烤吐司,這種食物通常在早晨被快速製作和享用。類似地,Toast 消息通常在螢幕上短暫地「彈出」,並在一段時間後自動消失,就像一片烤好的吐司會迅速被拿走一樣。

這個名稱的由來反映了該 UI 元件的設計意圖:向使用者提供即時、短暫且不干擾的通知。這種 UI 設計模式允許應用程式在不打斷用戶主要活動的情況下,向其提供重要的資訊或回饋。因此,Toast 這個名稱形象地表達了這類消息的快速性和短暫性。

Element Plus 的文件則說明,Notification 通常用於被動提醒或系統級別的通知,例如收到推播訊息或帳號即將被登出;而 Message 則用於提供使用者主動操作的回饋,例如新增、修改或刪除資料的成功或失敗。

或許在專案中不一定會區分得這麼詳細,但如果專案比較龐大且有不同的通知類型,這樣的簡單區分有助於溝通協作更加順暢。

元件架構

AtomicModal 架構圖

  1. State:Toast 的狀態。
  2. Message:Toast 的內容。
  3. Action(Close):關閉 Toast 的按鈕。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Toast(Message)、Notification 與 Snackbar 元件是如何設計的。

Element Plus

Element Plus Message

import { h } from 'vue'
import { ElMessage } from 'element-plus'

const open = () => {
  ElMessage('This is a message.')
}

const openVn = () => {
  ElMessage({
    message: h('p', { style: 'line-height: 1; font-size: 14px' }, [
      h('span', null, 'Message can be '),
      h('i', { style: 'color: teal' }, 'VNode'),
    ]),
  })
}

const openHTML = () => {
  ElMessage({
    dangerouslyUseHTMLString: true,
    message: '<strong>This is <i>HTML</i> string</strong>',
  })
}

與其他元件非常不同,ElMessage 在使用時會當作 function 呼叫,而不是當作 component 使用。這意味著我們可以在任意時機點開啟 message,而不需要在 template 中預先準備好元件。此外,ElMessage 也支援傳入 VNode 或是 HTML 作為內容,這為顯示比較複雜結構的 Message 提供了一定的自定義能力。

Vuetify

Vuetify Snackbar

<template>
  <div class="text-center">
    <VBtn
      color="red-darken-2"
      @click="snackbar = true"
    >
      Open Snackbar
    </VBtn>

    <VSnackbar
      v-model="snackbar"
      multi-line
    >
      {{ text }}

      <template v-slot:actions>
        <VBtn
          color="red"
          variant="text"
          @click="snackbar = false"
        >
          Close
        </VBtn>
      </template>
    </VSnackbar>
  </div>
</template>

Vuetify 的 <VSnackbar> 使用方式與我們熟悉的元件相似,需要在 template 中預先準備好元件,並透過 v-model 控制開啟與關閉。此外,依循 Material Design 的規範,<VSnackbar> 一次只會出現一個,不會有多個 Snackbar 同時出現。

Nuxt UI

Nuxt UI Notification

<template>
  <div>
    <UContainer>
      <NuxtPage />
    </UContainer>

    <UNotifications />
  </div>
</template>

在 Nuxt UI 中,使用 Notification 通常需要在最外層(例如 app.vue)加上 <UNotifications> 作為所有 Notification 的容器。

<script setup lang="ts">
const toast = useToast()
</script>

<template>
  <UButton label="Show toast" @click="toast.add({ title: 'Hello world!' })" />
</template>

開啟 Notification 時,不需要在 template 中預先準備好元件,而是使用 useToast 這個 Composable API 來取得開啟 Notification 的方法。

不難發現,許多 UI Library 在處理 Toast 時,都設計成讓使用者可以透過 function 呼叫來開啟元件,差別在於是否需要提供一個容器元件。

因此,這個元件的特別之處在於,除了設計元件本身外,我們還需要設計一個 Composable API 或 helper function,使使用者可以在任意時機點開啟 Toast,而不需要在每個 template 中預先準備好元件。

我們簡單列舉一些 <AtomicToast> 元件的功能需求:

  • 可以使用 type 來設定 Toast 的樣式,例如 successwarningdangerinfo
  • 可以使用 message 來設定 Toast 的內容。
  • 可以使用 duration 來設定 Toast 的顯示時間。

使用方式如下:

我們選用與 Nuxt UI 類似的方式,透過 Composable API 搭配 <AtomicToasts> 來開啟 Toast。

<template>
  <div>
    <RouterView />
    <AtomicToasts />
  </div>
</template>
const { open } = useToast()

const onClick = () => {
  open({
    type: 'success',
    message: 'Hello world!',
  })
}

元件實作

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

名稱 型別 預設值 說明
type success, warning, danger, info info Toast 的樣式
message string Toast 的內容
duration number 3000 Toast 的顯示時間
interface AtomicToastProps {
  message: string;
  type?: 'success' | 'warning' | 'danger' | 'info';
  duration?: number;
}

const props = withDefaults(defineProps<AtomicToastProps>(), {
  type: 'success',
  duration: 3000,
});

樣式與 template 結構會依專案需求有所不同,這裡僅提供一個簡單範例。

<template>
  <div
    class="atomic-toast"
    :class="{
      [`atomic-toast--${type}`]: !!type,
    }"
  >
    <div class="atomic-toast__container">
      <component
        :is="ICON_MAP[type]"
        class="atomic-notification__status"
        fill="var(--toast-color)"
        height="20"
        width="20"
      />
      <p class="atomic-toast__message">
        {{ message }}
      </p>
      <button
        class="atomic-toast__close"
        type="button"
        @click="onClose"
      >
        <CloseSvg
          height="16"
          width="16"
        />
      </button>
    </div>
  </div>
</template>

我們的 .atomic-toast 需要使用 position: fixed 來固定在畫面上,這裡設計讓 Toast 出現在右上角。

.atomic-toast {
  position: fixed;
  top: 16px;
  right: 16px;

  &__container {
    display: flex;
    background-color: white;
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
  }
}

實現duration 的功能,我們可以使用 setTimeout 來在 duration 時間後自動關閉 Toast。如果設定為 0 則永遠不消失。

const active = ref(false);

let timeout: NodeJS.Timeout | undefined;

const onClose = () => {
  active.value = false;
  timeout && clearTimeout(timeout);
};

onMounted(() => {
  active.value = true;

  if (props.duration) {
    timeout = setTimeout(onClose, props.duration);
  }
});

為了讓 Toast 開啟時能夠有較平滑的效果,我們可以使用 <Transition> 搭配先前設計的動畫效果。

<template>
  <Transition name="transition-slide-left">
    <div
      v-show="active"
      class="atomic-toast"
    >
      <div class="atomic-toast__container">
        <!-- 略 -->
      </div>
    </div>
  </Transition>
</template>

有了基礎的樣式後,我們來思考如何使用 Composable API 來開啟 Toast。

我們可以應用在 <AtomicModal> 實作中提到的「資料管理中心」架構來處理。每當呼叫 open 時,就向資料管理中心新增一筆 Toast 資料,這樣就可以在元件內部使用 v-for 來顯示所有的 <AtomicToast>

AtomicToast 資料中心架構圖

先實作出資料管理中心吧!我們在這裡使用一個陣列來存放所有的 Toast 資料。

const toasts = shallowReactive<Toast[]>([]);

這個 toasts 會提供給 <AtomicToasts> 使用 v-for 來顯示所有的 <AtomicToast>,因此我們至少需要 keypropsprops 可接受的參數與 <AtomicToast> 完全一樣。

import type { AtomicToastProps } from '~/components/AtomicToast.vue';

type Toast = {
  key: symbol;
  props: AtomicToastProps;
};

接著我們需要 open function 來開啟 Toast。

open

const open = (options: AtomicToastProps) => {
  toasts.push({
    key: Symbol(),
    props: options,
  });
};

我們就初步完成了資料中心的基本設計,接下來將資料中心包成 Vue Plugin 並在初始化時安裝使用。

const TOASTS_MANAGEMENT: InjectionKey<ToastsManager> = Symbol();
const TOASTS: InjectionKey<Toast[]> = Symbol();

export function createToastsManager() {
  // 略

  const manager: ToastsManager = { open };

  return {
    app.provide(TOASTS, toasts);
    app.provide(TOASTS_MANAGEMENT, manager);
  }
}

記得在 Vue 初始化時安裝這個 Plugin。

const toasts = createToastsManager()

const app = createApp(App);
app.use(toasts);
app.mount('#app');

為了方便使用,我們可以再寫兩個 Composable API,一方面方便 <AtomicToasts> 使用,另一方面也讓我們在開發時可以使用 useToast() 而不是 inject(TOASTS_MANAGEMENT)

useToasts

export function useToasts() {
  const context = inject(TOASTS, null);
  if (!context) throw new Error('error');
  return context;
}

useToast

export function useToast() {
  const context = inject(TOASTS_MANAGEMENT, null);
  if (!context) throw new Error('error');
  return context;
}

現在我們可以在任意元件中使用 useToast() 回傳的 open 方法來向資料中心新增 Toast 資料,接著實作 <AtomicToasts> 來顯示這些 Toast。

<script setup lang="ts">
import AtomicToast from '~/components/AtomicToast.vue';
import { useToasts } from '~/plugins/toasts';

const toasts = useToasts();
</script>

<template>
  <Teleport to="body">
    <AtomicToast
      v-for="message in toasts"
      :key="message.key"
      v-bind="message.props"
    />
  </Teleport>
</template>

現在 <AtomicToasts> 完成了它的任務,我們已經可以在元件內部開啟 Toast 了。

const { open } = useToast();

const onClick = () => {
  open({
    type: 'success',
    message: 'Hello world!',
  });
};

如果一次只開一個 Toast,看起來沒什麼大問題,但如果同時開啟多個 Toast,就會發現它們全部重疊在一起,這是因為我們並沒有給予每個 Toast 不同的偏移。此外,我們也需要考慮前面的 Toast 關閉後,後面的 Toast 如何調整位置,以及 Toast 關閉後從資料中心移除的問題。

我們先從最簡單的步驟開始:Toast 關閉後要從資料中心移除

由於 <AtomicToast> 有過場動畫處理,所以我們需要等過場動畫結束後再從資料中心移除 Toast。

interface AtomicToastEmits {
  (event: 'destroy'): void;
}

const emit = defineEmits<AtomicToastEmits>();

const onAfterLeave = () => emit('destroy');
<template>
  <Transition
    name="transition-slide-left"
    @after-leave="onAfterLeave"
  >
    <div
      v-show="active"
      class="atomic-toast"
    >
      <div class="atomic-toast__container">
        <!-- 略 -->
      </div>
    </div>
  </Transition>
</template>

在資料中心裡新增一個 handleDestroy,處理 Toast 過場動畫結束後觸發的 onDestroy 事件。

const handleDestroy = (key: symbol) => {
  const index = toasts.findIndex(message => message.key === key);
  if (index === -1) return;
  toasts.splice(index, 1);
};

const open = (options: AtomicToastProps) => {
  const key = Symbol();
  toasts.push({
    key,
    props: {
      ...options,
      onDestroy: () => handleDestroy(key),
    },
  });

  return key;
};

接著處理:計算每個 Toast 的偏移(offset)

我們在 <AtomicToasts> 中新增一個 offset 的 props,<AtomicToast> 會依照這個 props 來調整定位的位置。

interface AtomicToastsProps {
  offset?: number;
}

const props = withDefaults(defineProps<AtomicToastsProps>(), {
  offset: 16,
});
<template>
  <Transition name="transition-slide-left">
    <div
      v-show="active"
      class="atomic-toast"
      :style="{
        '--toast-offset': offset,
      }"
    >
      <div class="atomic-toast__container">
        <!-- 略 -->
      </div>
    </div>
  </Transition>
</template>
.atomic-toast {
  position: fixed;
  top: calc(var(--toast-offset, 16) * 1px);
  right: 16px;
}

offset 的計算方式是將前一個 Toast 的 offset 加上前一個 Toast 的高度。

previous.props.offset + previous.element.offsetHeight

為了讓資料中心能夠計算 offset,我們在 Toast 開始處理入場動畫時,將 Toast 的 DOM 元素存到資料中心。

interface AtomicToastEmits {
  (event: 'open', value: Element): void;
}

const emit = defineEmits<AtomicToastEmits>();

const onEnter = (element: Element) => emit('open', element);
<template>
  <Transition
    name="transition-slide-left"
    @enter="onEnter"
  >
    <div
      v-show="active"
      class="atomic-toast"
      :style="{
        '--toast-offset': offset,
      }"
    >
      <div class="atomic-toast__container">
        <!-- 略 -->
      </div>
    </div>
  </Transition>
</template>

在資料中心新增一個 handleOpen,處理 Toast 進場動畫開始時觸發的 onOpen 事件。

const handleOpen = (id: symbol, element: Element) => {
  const index = toasts.findIndex(message => message.key === id);
  if (index === -1) return;
  const message = toasts[index];

  message.element = element;
  handleUpdateOffset(index);
};

const open = (options: AtomicToastProps) => {
  const key = Symbol();
  toasts.push({
    key,
    props: {
      ...options,
      onOpen: (element) => handleOpen(key, element),
    },
  });

  return key;
};

handleOpen 會將 Toast 的 DOM 元素存到資料中心,這樣我們就可以在 handleUpdateOffset 中計算每個 Toast 的 offset

const handleUpdateOffset = (index: number, collections = toasts) => {
  const previous = collections[index - 1];

  let offset = GAP_SIZE;
  if (previous) {
    offset += previous.props.offset || 0;
    offset += 
      previous.element instanceof HTMLElement
        ? previous.element.offsetHeight
        : 0;
  }

  collections[index].props.offset = offset;
};

最後解決:Toast 關閉後重新計算受影響的 offset

interface AtomicToastEmits {
  (event: 'close'): void;
}

const emit = defineEmits<AtomicToastEmits>();

const onClose = () => {
  // 略

  emit('close');
}

在資料中心新增一個 handleClose,處理 Toast 關閉時觸發的 onClose 事件。

不需要全部重新計算,只需找到當前(關閉) Toast 的 index,然後從這個 index 開始重新計算 offset

const handleClose = (key: symbol) => {
  const index = toasts.findIndex(message => message.key === key);
  const cloned = toasts.slice();

  cloned.splice(index, 1);

  // 從 index 開始更新 offset
  for (let i = index; i < cloned.length; i++) {
    handleUpdateOffset(i, cloned);
  }
};

這樣各個面向的問題都解決了,我們完成了 <AtomicToast> 的實作。

AtomicToast Demo

進階功能

如果我們可以不透過在 App.vueapp.vue)中加入 <AtomicToasts> 來渲染 Toast,而是透過 open(這個段落為了區分改用 notify)直接開啟 Toast,這樣的使用方式會更加方便。

import notify from '~/helpers/notify'

notify({
  type: 'success',
  message: 'Hello world!',
})

為了區分差異,這裡使用 notify 為 function 名稱。

既然我們想要省略 <AtomicToasts>,這就表示我們需要自己處理元件渲染到畫面上的工作。我們可以使用 Vue 提供的 rendercreateVNode 來渲染 <AtomicToast> 元件到畫面上。

import { createVNode, render } from 'vue';
import AtomicToast from '~/components/AtomicToast.vue';

export default function notify(options) {
  // 先略過

  // 將 Vue Component 轉換成 VNode,並渲染到畫面上
  const container = document.createElement('div');
  const vnode = createVNode(AtomicToast, props);
  render(vnode, container);

  document.body.appendChild(container.firstElementChild!);
}

接著,有一些部分需要微調,原本我們的資料管理中心有個 toasts,它現在純粹用於存放 Toast 資料,而不是用來渲染到畫面上的,所以不需要提供響應能力。

const toasts: Toast[] = [];

// 原本的 open function
export default function notify(options) {
  // 先略過

  // 將 Vue Component 轉換成 VNode,並渲染到畫面上
  const container = document.createElement('div');
  const vnode = createVNode(AtomicToast, props);
  render(vnode, container);

  document.body.appendChild(container.firstElementChild!);
}

Toast 裡面的屬性也需要做一些調整,原本存放的 element 改存放 vnode: VNode,我們可以透過 vnode 來取得 el 與 Component 上的 props

type Toast = {
  key: symbol;
  props: AtomicToastProps;
  vnode?: VNode;
};

接著 handleOpen 的第二個參數原本是接收一個 element,現在改為接收 vnode

const handleOpen = (key: symbol, vnode: VNode) => {
  const index = toasts.findIndex(toast => toast.key === key);
  if (index === -1) return;
  const toast = toasts[index];

  toast.vnode = vnode;
  handleUpdateOffset(index);
};

handleUpdateOffset 內部也因為我們改存 vnode 而需要做一些調整。

const handleUpdateOffset = (index: number, collections = toasts) => {
  const previous = collections[index - 1];

  let offset = GAP_SIZE;
  if (previous) {
    // 這裡改從 node 取得 el(element)與 component 上的 props
    const { el, component } = previous.vnode!;
    offset += (component?.props.offset as number) || 0;
    offset += 
      el instanceof HTMLElement
        ? el.offsetHeight
        : 0;
  }

  const { component } = collections[index].vnode!;
  component!.props.offset = offset
};

剩下的 handleClosehandleDestroy 直接搬過來即可。

const handleClose = (key: symbol) => {
  const index = toasts.findIndex(toast => toast.key === key);
  const cloned = toasts.slice();

  cloned.splice(index, 1);

  // 從 index 開始更新 offset
  for (let i = index; i < cloned.length; i++) {
    handleUpdateOffset(i, cloned);
  }
};

const handleDestroy = (key: symbol) => {
  const index = toasts.findIndex(toast => toast.key === key);
  if (index === -1) return;
  toasts.splice(index, 1);
};

最後我們還有個小地方要調整,因為我們是手動渲染到畫面上,所以在 onDestroy 時需要手動移除元素。

const props = {
  onDestroy: () => {
    handleDestroy(key);
    render(null, container);
  }
};

完整的 notify function 如下:

export default function notify(options: AtomicToastProps) {
  const key = Symbol();
  const props: ComponentProps<typeof Atomic> = {
    ...options,
    offset: 16,
    onOpen: () => handleOpen(key, vnode),
    onClose: () => handleClose(key),
    onDestroy: () => {
      handleDestroy(key);
      render(null, container);
    }
  };
  
  toasts.push({
    key,
    props,
  });

  // 將 Vue Component 轉換成 VNode,並渲染到畫面上
  const container = document.createElement('div');
  const vnode = createVNode(AtomicToast, props);
  render(vnode, container);

  document.body.appendChild(container.firstElementChild!);
}

這樣我們就完成了 notify function 的實作,現在我們可以在任意元件中使用 notify 來開啟 Toast。

無障礙

角色 Role

使用 role="alert"role="status" 來標記 Toast 元件。這些 ARIA 角色會通知輔助技術(如螢幕閱讀器)有新資訊顯示。

通常使用 role="alert" 來標記重要且需要立即告知使用者的訊息,例如錯誤提示;而 role="status" 則用於一般的狀態更新或成功訊息。

<template>
  <Transition name="transition-slide-left">
    <div
      class="atomic-toast"
      role="status"
    >
      <!-- 略 -->
    </div>
  </Transition>
</template>

ARIA 屬性

  1. aria-live: 這個屬性用於告訴輔助技術如何宣讀更新的內容。aria-live="assertive" 用於高優先級的訊息,會立即打斷當前的發言。而 aria-live="polite" 則用於較低優先級的訊息,僅在輔助技術完成當前發言後再宣讀新內容。

  2. aria-atomic: 設定為 true 時,當 Toast 的內容改變時,整個元素的內容會被重新宣讀,而不僅僅是變更的部分。

預設情況下,如果設定 role="alert",這個 Role 本身就預設 aria-live="assertive"aria-atomic="true";而 role="status" 這個 Role 本身預設 aria-live="polite"aria-atomic="true"

總結

在這篇 <AtomicToast> 元件實作文章中,我們分析了它在不同 UI Library 中的名稱和使用方式。

在實作過程中,我們探討了如何使用 Composable API 和 Vue Plugin 來管理和渲染 Toast 元件。我們實現了一個可以在任意時機點開啟 Toast 的功能,並且計算了每個 Toast 的顯示位置與更新。

在進階功能中,我們也嘗試了 Element Plus 採用的作法,這讓我們不需要 <AtomicToasts> 這個容器元件,只需使用 Vue 的 rendercreateVNode 就可以自行將 <AtomicToast> 渲染到畫面上。

雖然 Element Plus 採用的作法提供了更好的開發體驗,但在設計上相對更為複雜,需要手動控制元件的出現,而非完全依賴響應式設計,這需要非常熟悉 Vue 的 API 才能夠比較從容地掌握。

我自己雖然很喜歡 Element Plus 的這個 API 設計,但在設計上有時候我還是會更偏向選擇有 <AtomicToasts> 的方式,這樣的設計更好地應用了 Vue 的響應式設計,也更容易讓其他開發人員理解。

參考資料


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

尚未有邦友留言

立即登入留言