构建消息框组件

简要介绍了如何构建自适应且可访问的 Toast 组件。

在这篇博文中,我想分享一下如何构建消息框组件的想法。试用演示

演示

如果您更喜欢视频,请观看此帖子的 YouTube 版本:

概览

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

互动次数

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

Markup

<output> 元素非常适合用于显示 Toast,因为它会向屏幕阅读器读出内容。正确的 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 容器

Toast 容器会执行显示 Toast 的所有布局工作。与视口的距离是 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 元素上。

除了在视口中定位自身之外,Toast 容器还是一种网格容器,可以对齐和分布 Toast。使用 justify-content 可将项作为一组居中,使用 justify-items 可将项单独居中。添加一点 gap,以免吐司重叠。

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

显示 CSS 网格叠加在通知群组上的屏幕截图,这次突出显示了通知子元素之间的间距。

GUI Toast

单个消息框具有一些 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。

消息框容器

Toast 不支持互动,点按或滑动 Toast 不会产生任何效果,但它们目前会消耗指针事件。使用以下 CSS 可防止 Toast 盗取点击。

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

动画

新的 Toast 应在进入屏幕时显示动画。默认情况下,通过将 translate 值设置为 0 来适应减少的动作,但在动作偏好媒体查询中将动作值更新为长度。所有用户都会看到动画,但只有部分用户会看到 Toast 移动一段距离。

以下是用于显示 Toast 动画的关键帧。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
}

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

系统会在模块内部调用 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
}

管理一个或多个 Toast

JavaScript 现在会向文档中添加一个用于包含 Toast 的容器,并且可以添加创建的 Toast。addToast() 函数会协调处理一个或多个消息框。首先检查显示 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、退出动画。当有现有吐司时,系统会调用 flipToast(),并采用 Paul Lewis 所称的 FLIP 技术。其基本思路是计算添加新 Toast 前后容器的位置差异。可以想象成标记烤箱现在的位置和要放置的位置,然后从其原来的位置以动画形式呈现它。

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 网格会提升布局。添加新消息框时,网格会将其放在开头,并将其与其他消息框分隔开。同时,系统会使用 Web 动画为容器从旧位置添加动画效果。

将所有 JavaScript 组合在一起

调用 Toast('my first toast') 时,系统会创建一个 Toast 并将其添加到页面中(甚至可能为容器添加动画以容纳新的 Toast),然后返回一个promise,并监控所创建的 Toast 以便在 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() 映射。由于我为 Toast 使用了多个关键帧动画,因此为了确信所有动画都已完成,必须从 JavaScript 请求每个动画,并监控每个动画的 finished 承诺以确认其已完成。allSettled 会为我们完成此工作,在其所有 promise 都已实现后,将自身解析为已完成。使用 await Promise.allSettled() 意味着下一行代码可以放心地移除该元素,并假定该通知已完成其生命周期。最后,调用 resolve() 会执行高级 Toast 承诺,以便开发者在 Toast 显示后进行清理或执行其他工作。

export default Toast

最后,将 Toast 函数从模块中导出,以便其他脚本导入和使用。

使用 Toast 组件

如需使用 Toast 或 Toast 的开发者体验,请导入 Toast 函数并使用消息字符串调用该函数。

import Toast from './toast.js'

Toast('Wizard Rose added to cart')

如果开发者想在显示 Toast 后执行清理工作或其他操作,可以使用 async 和 await

import Toast from './toast.js'

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

总结

现在您已经知道我是如何解决的,您会怎么做?

让我们多元化我们的方法,了解在 Web 上构建的所有方式。 制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!

社区混剪作品