建立浮動式訊息元件

概略說明如何建構可自動調整且符合無障礙設計的 Toast 元件。

在本篇文章中,我想分享如何建構 Toast 元件的想法。歡迎前往示範操作。

示範

如果你偏好觀看影片,請參閱這篇文章的 YouTube 版本:

總覽

浮動式訊息是向使用者提供非互動式、被動式或非同步簡短訊息。一般來說,這類訊息會做為介面回饋模式使用,用於通知使用者操作結果。

互動

與通知、快訊提示不同,彈出式通知並非互動式,因此無法關閉或保留。通知適用於更重要的資訊、需要互動的同步訊息,或系統層級訊息 (而非網頁層級)。與其他通知策略相比,彈出式通知較為被動。

標記

<output> 元素是顯示快訊的理想選擇,因為它會向螢幕閱讀器宣告。正確的 HTML 可提供安全的基礎,讓我們用 JavaScript 和 CSS 進行強化,而且會用到大量的 JavaScript。

浮動式訊息

<output class="gui-toast">Item added to cart</output>

您可以新增 role="status",讓內容更具包容性。如果瀏覽器未依照規格為 <output> 元素提供隱含的角色,則會提供備用方案。

<output role="status" class="gui-toast">Item added to cart</output>

顯示訊息容器

一次最多可顯示多個浮動式通知。為了協調多個 Toast,我們使用了容器。這個容器也會處理畫面上的浮動式訊息的位置。

<section class="gui-toast-group">
  <output role="status">Wizard Rose added to cart</output>
  <output role="status">Self Watering Pot added to cart</output>
</section>

版面配置

我選擇將浮動式訊息固定在可視區域的 inset-block-end 中。如果新增更多浮動式訊息,這些浮動式訊息就會從該畫面邊緣堆疊。

GUI 容器

訊息容器會執行所有訊息顯示的版面配置作業。它是視區的 fixed,並使用邏輯屬性 inset 指定要固定的邊緣,以及相同 block-end 邊緣的 padding 一小部分。

.gui-toast-group {
  position: fixed;
  z-index: 1;
  inset-block-end: 0;
  inset-inline: 0;
  padding-block-end: 5vh;
}

螢幕截圖:DevTools 方塊大小和邊框間距重疊在 .gui-toast-container 元素上。

除了在可視區域中定位自身之外,訊息方塊容器也是可對齊及分發訊息方塊的格線容器。項目會以 justify-content 置中為群組,並以 justify-items 個別置中。加入一點 gap,讓 Toast 不會重疊。

.gui-toast-group {
  display: grid;
  justify-items: center;
  justify-content: center;
  gap: 1vh;
}

螢幕截圖:在彈出式通知群組上疊加 CSS 格線,這次強調彈出式通知子項之間的空格和間距。

GUI Toast

個別彈出式通知含有 padding,部分圓角使用 border-radius,並提供 min() 函式,協助設定行動裝置和電腦的大小。下列 CSS 中的回應式大小可避免彈出式通知的寬度超過可視區域或 25ch 的 90%。

.gui-toast {
  max-inline-size: min(25ch, 90vw);
  padding-block: .5ch;
  padding-inline: 1ch;
  border-radius: 3px;
  font-size: 1rem;
}

單一 .gui-toast 元素的螢幕截圖,顯示邊框間距和邊框半徑。

樣式

設定版面配置和定位後,請新增 CSS,以便配合使用者設定和互動。

Toast 容器

Toast 並非互動式,輕觸或滑動不會有任何效果,但目前會使用指標事件。使用下列 CSS 避免彈出式通知竊取點擊。

.gui-toast-group {
  pointer-events: none;
}

GUI 浮動式訊息

為 Toast 提供淺色或深色自適應主題,並使用自訂屬性、HSL 和偏好媒體查詢。

.gui-toast {
  --_bg-lightness: 90%;

  color: black;
  background: hsl(0 0% var(--_bg-lightness) / 90%);
}

@media (prefers-color-scheme: dark) {
  .gui-toast {
    color: white;
    --_bg-lightness: 20%;
  }
}

動畫

新的浮動式訊息應在進入螢幕畫面時同時顯示動畫。藉由將 translate 值預設為 0,達到減少動作的效果,但在動作偏好設定媒體查詢中,將動態值更新為長度。每位使用者都會看到一些動畫,但只有部分使用者會看到彈出式通知移動一段距離。

以下是用於浮動式訊息動畫的主要畫面格。CSS 會控制 Toast 的顯示、等待和關閉動畫。

@keyframes fade-in {
  from { opacity: 0 }
}

@keyframes fade-out {
  to { opacity: 0 }
}

@keyframes slide-in {
  from { transform: translateY(var(--_travel-distance, 10px)) }
}

接著,浮動式訊息元素會設定變數,並安排主要畫面格的時間。

.gui-toast {
  --_duration: 3s;
  --_travel-distance: 0;

  will-change: transform;
  animation: 
    fade-in .3s ease,
    slide-in .3s ease,
    fade-out .3s ease var(--_duration);
}

@media (prefers-reduced-motion: no-preference) {
  .gui-toast {
    --_travel-distance: 5vh;
  }
}

JavaScript

有了可供螢幕閱讀器存取的 HTML 樣式和樣式,您就需要使用 JavaScript 來根據使用者事件,協調建立、新增和刪除 Toast。彈出式訊息元件的開發人員體驗應盡可能簡單,且易於上手,例如:

import Toast from './toast.js'

Toast('My first toast')

建立 Toast 群組和 Toast

當 Toast 模組從 JavaScript 載入時,必須建立 Toast 容器並將其新增至頁面。我選擇在 body 前方加入元素,這樣就不會發生 z-index 堆疊問題,因為容器位於所有主體元素的容器上方。

const init = () => {
  const node = document.createElement('section')
  node.classList.add('gui-toast-group')

  document.firstElementChild.insertBefore(node, document.body)
  return node
}

螢幕截圖:頭部和內文標記之間的浮動通知群組。

init() 函式會在模組內部呼叫,將元素儲存為 Toaster

const Toaster = init()

您可以使用 createToast() 函式建立 Toast HTML 元素。這個函式需要 Toast 的文字,並建立 <output> 元素,以便透過一些類別和屬性裝飾,設定文字,然後傳回節點。

const createToast = text => {
  const node = document.createElement('output')
  
  node.innerText = text
  node.classList.add('gui-toast')
  node.setAttribute('role', 'status')

  return node
}

管理一或多個浮動式訊息

JavaScript 現在會在文件中加入容器,以納入含有浮動式訊息的容器,現在也能新增已建立的浮動式訊息。addToast() 函式會協調處理一個或多個 Toast。首先檢查 Toast 數量,以及動畫是否正常運作,然後使用這項資訊附加 Toast,或執行一些精美的動畫,讓其他 Toast 出現以便為新 Toast 騰出空間。

const addToast = toast => {
  const { matches:motionOK } = window.matchMedia(
    '(prefers-reduced-motion: no-preference)'
  )

  Toaster.children.length && motionOK
    ? flipToast(toast)
    : Toaster.appendChild(toast)
}

新增第一個彈出式通知時,Toaster.appendChild(toast) 會在觸發 CSS 動畫的頁面中新增彈出式通知:動畫進入、等待 3s、動畫離開。當有現有的 Toast 時,系統會呼叫 flipToast(),並採用 Paul LewisFLIP 技術。這項概念是計算新增新彈出式通知前後,容器位置的差異。想像一下,就像標記烤麵包機目前的位置,接著以動畫的方式指出烤麵包機目前的位置,

const flipToast = toast => {
  // FIRST
  const first = Toaster.offsetHeight

  // add new child to change container size
  Toaster.appendChild(toast)

  // LAST
  const last = Toaster.offsetHeight

  // INVERT
  const invert = last - first

  // PLAY
  const animation = Toaster.animate([
    { transform: `translateY(${invert}px)` },
    { transform: 'translateY(0)' }
  ], {
    duration: 150,
    easing: 'ease-out',
  })
}

CSS 格線會負責處理版面配置。新增的浮動視窗會顯示在格狀結構的開頭,並與其他浮動視窗保持間距。同時,網路動畫可用來從舊位置建立容器動畫。

將所有 JavaScript 結合在一起

呼叫 Toast('my first toast') 時,系統會建立並新增 Toast 至頁面 (甚至可能會為容器提供動畫,以便容納新的 Toast),並傳回 promise,然後為 CSS 動畫完成 (三個關鍵影格動畫) 監控 Toast,以便解析 promise。

const Toast = text => {
  let toast = createToast(text)
  addToast(toast)

  return new Promise(async (resolve, reject) => {
    await Promise.allSettled(
      toast.getAnimations().map(animation => 
        animation.finished
      )
    )
    Toaster.removeChild(toast)
    resolve() 
  })
}

我認為這段程式碼中令人困惑的部分是 Promise.allSettled() 函式和 toast.getAnimations() 對應。由於我為 Toast 使用了多個關鍵影格動畫,為了確保所有動畫都已完成,每個動畫都必須從 JavaScript 要求,並觀察每個 finished 承諾是否已完成。allSettled 能夠有效解決問題,在所有承諾都完成後,將自身徹底解決。使用 await Promise.allSettled() 表示下一個程式碼行可以放心移除元素,並假設 Toast 已完成其生命週期。最後,呼叫 resolve() 可履行高層級 Toast 承諾,讓開發人員在 Toast 顯示後進行清理或執行其他工作。

export default Toast

最後,Toast 函式會從模組匯出,供其他指令碼匯入及使用。

使用 Toast 元件

如要使用 Toast 或 Toast 的開發人員體驗,請匯入 Toast 函式,並使用訊息字串呼叫該函式。

import Toast from './toast.js'

Toast('Wizard Rose added to cart')

如果開發人員想要清理工作或任何其他,在浮動式訊息顯示之後,可以使用 async 和 await

import Toast from './toast.js'

async function example() {
  await Toast('Wizard Rose added to cart')
  console.log('toast finished')
}

結論

既然你知道我如何做到,你會怎麼做呢? 🙂

讓我們多方嘗試,瞭解在網路上建構應用程式的所有方式。建立示範、張貼推文 連結,以便我們將其新增至下方的社群重混專區!

社群重混作品