构建消息框组件

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

在本文中,我想分享有关如何构建 Toast 组件的想法。试用演示版

演示

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

概览

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

互动次数

与通知、提醒提示不同,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

单个 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 不支持互动,点按或滑动 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 将在一个动画中控制消息框的进入、等待和退出。

@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 堆叠问题不太可能出现,因为容器在所有 body 元素都位于该容器上方。

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

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

显示在 head 和 body 标记之间的 Toast 组的屏幕截图。

系统会在模块内部调用 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 网格会提升布局。添加新 Toast 时,网格会将其放在开头,并与其他 Toast 之间留出间距。同时,系统会使用 Web 动画为容器从旧位置添加动画效果。

将所有 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() 映射。由于我为 Toast 使用了多个关键帧动画,因此为了确信所有动画都已完成,必须从 JavaScript 请求每个动画,并监控每个动画的 finished 承诺以确认其已完成。allSettled 就可以做到这一点,它的所有 promise 都被执行后,它会自行解析为完成。使用 await Promise.allSettled() 意味着下一行代码可以放心地移除该元素,并假定该通知已完成其生命周期。最后,调用 resolve() 会执行高级 Toast 承诺,以便开发者在 Toast 显示后进行清理或执行其他工作。

export default 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 上构建应用的所有方法。 创建一个演示,在 Twitter 微博中发送链接,然后我会将其添加到下面的“社区混剪”部分!

社区混剪作品