简要介绍了如何构建自适应且可访问的 Toast 组件。
在本文中,我想分享一下关于如何构建 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。为了协调多个通知,系统会使用容器。此容器还会处理屏幕上显示的 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;
}
除了在视口中定位自身之外,Toast 容器还是一种网格容器,可以对齐和分布 Toast。使用 justify-content
可将项作为一组居中,使用 justify-items
可将项单独居中。添加一点 gap
,以免吐司重叠。
.gui-toast-group {
display: grid;
justify-items: center;
justify-content: center;
gap: 1vh;
}
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;
}
样式
设置布局和定位后,添加有助于适应用户设置和互动的 CSS。
Toast 容器
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%;
}
}
动画
新的 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)) }
}
然后,Toast 元素会设置变量并协调关键帧。
.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
}
管理一个或多个 Toast
JavaScript 现在会向文档中添加一个用于包含 Toast 的容器,并且可以添加创建的 Toast。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
、退出动画。当有现有吐司时,系统会调用 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')
时,系统会创建一个 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 上构建的所有方式。 制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!
社区混剪作品
- 使用 HTML/CSS/JS 的 @_developit:演示和代码
- Joost van der Schee:HTML/CSS/JS 演示和代码