构建消息框组件

简要介绍如何构建自适应且可访问的消息框组件。

在这篇博文中,我想与大家分享有关如何构建消息框组件的想法。查看演示

演示

如果你更喜欢视频,可以参考本博文的 YouTube 版本:

概览

消息框是面向用户的非互动、被动和异步短消息。通常,它们用作界面反馈模式,用于告知用户操作结果。

互动次数

消息框与通知、提醒提示不同,因为它们不具有互动性;不应关闭或保留。通知适用于更重要的信息、需要互动的同步消息传递或系统级消息(而不是页面级)。消息框比其他通知策略更被动。

Markup

<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>

一个消息框容器

一次可以显示多个消息框。为了编排多个消息框,会使用容器。此容器还会处理消息框在屏幕上的位置。

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

显示开发者工具框大小和内边距叠加在 .gui-toast-container 元素上的屏幕截图。

除了在视口中定位自身的位置之外,消息框容器还是可以对齐和分布消息框的网格容器。各项通过 justify-content 作为一个群组居中,并通过 justify-items 单独居中。添加一点 gap,以便消息框不会触碰。

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

包含消息框组上的 CSS 网格叠加层的屏幕截图,这次突出显示了消息框子元素之间的空间和间距。

GUI 吐司

单个消息框包含一些 padding、一些较柔和的角落采用了 border-radius,还有一个 min() 函数可帮助调整移动设备和桌面设备的尺寸。以下 CSS 中的自适应尺寸可防止消息框宽度超过视口的 90%(即 25ch)。

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

单个 .gui-toast 元素的屏幕截图,其中显示了内边距和边框半径。

风格

设置布局和位置后,添加有助于适应用户设置和互动的 CSS。

消息框容器

消息框不具有互动性,点按或滑动消息框不会执行任何操作,但它们目前会使用指针事件。使用以下 CSS 可防止消息框窃取点击数据。

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

GUI 吐司

使用自定义属性、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 将在一个动画中控制消息框的进入、等待和退出。

@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,从而根据用户事件编排消息框的创建、添加和销毁操作。消息框组件的开发者应尽可能少且容易上手,如下所示:

import Toast from './toast.js'

Toast('My first toast')

创建消息框组和消息框

从 JavaScript 加载消息框模块时,它必须创建一个消息框容器并将其添加到页面中。我选择在 body 之前添加该元素,这样将不太可能出现 z-index 堆叠问题,因为容器位于所有正文元素的容器上方。

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

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

head 和 body 标记之间的消息框组的屏幕截图。

系统会在模块内部调用 init() 函数,并将元素存储为 Toaster

const Toaster = init()

消息框 HTML 元素的创建是通过 createToast() 函数完成的。该函数需要消息框的一些文本,创建一个 <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() 函数会编排处理一个或多个消息框。首先检查消息框的数量以及动作是否正常,然后使用此信息附加消息框或制作一些精美的动画,使其他消息框看起来为新的消息框“腾出空间”。

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,以动画形式输出。当存在消息框时,系统会调用 flipToast(),它采用 Paul Lewis 的名为 FLIP 的技术。具体做法是计算添加新消息框之前和之后在容器中的位置差异。这就像标记烤面包机现在的位置和即将到来的位置,然后以动画形式呈现从当前位置到现在的位置。

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') 后,系统会创建一个消息框并将其添加至页面(甚至可能为容器添加动画效果以适应新消息框),然后返回一个 promise,并监控已创建的消息框是否完成 CSS 动画(三个关键帧动画),以执行 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() 映射。由于我为消息框使用了多个关键帧动画,以便确信所有动画已完成,必须向 JavaScript 请求每个动画,并且它们的每个 finished 都保证观察到完成。这时,allSettled 就行了,它会在实现所有 promise 后将自身解析为完成状态。使用 await Promise.allSettled() 意味着下一行代码可以放心地移除该元素并假定消息框已完成其生命周期。最后,调用 resolve() 会执行高级消息框 promise,以便开发者可以在消息框显示后进行清理或执行其他工作。

export default 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')
}

总结

现在你已经知道我是怎么做的,希望你怎么办 ‽ 🙂?

下面,我们就来介绍一下我们的方法多样化,并了解在 Web 上构建网站的所有方法。 只需创建一个演示,点击 tweet me 链接,我就会将其添加到下方的“社区混剪”部分中!

社区混剪作品