关于如何构建自适应且无障碍的 Toast 组件的基础概述。
在这篇文章中,我想分享关于如何构建 Toast 组件的一些思考。请尝试演示。
如果您更喜欢视频,这里是这篇文章的 YouTube 版本
概述
Toast 是非交互式的、被动的和异步的短消息,供用户使用。通常,它们用作界面反馈模式,用于告知用户操作结果。
交互
Toast 与通知、警报和 提示 不同,因为它们是非交互式的;它们不打算被关闭或持久存在。通知用于更重要的信息、需要交互的同步消息或系统级消息(相对于页面级消息)。与其他通知策略相比,Toast 更为被动。
标记
<output>
元素是 Toast 的一个不错的选择,因为它会向屏幕阅读器播报。正确的 HTML 为我们使用 JavaScript 和 CSS 进行增强提供了安全的基础,并且会有大量的 JavaScript 代码。
一个 Toast
<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。为了编排多个 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>
布局
我选择将 Toast 固定到视口的 inset-block-end
,如果添加更多 Toast,它们将从该屏幕边缘堆叠。
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
,使 Toast 不会互相接触。
.gui-toast-group {
display: grid;
justify-items: center;
justify-content: center;
gap: 1vh;
}
GUI Toast
单个 Toast 具有一些 padding
,一些使用 border-radius
的柔和圆角,以及一个 min()
函数来帮助实现移动端和桌面端的大小调整。以下 CSS 中的响应式大小可防止 Toast 变得比视口的 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 是非交互式的,点击或滑动它们没有任何作用,但它们目前会消耗指针事件。使用以下 CSS 防止 Toast 窃取点击事件。
.gui-toast-group {
pointer-events: none;
}
GUI Toast
使用自定义属性、HSL 和首选媒体查询为 Toast 提供浅色或深色自适应主题。
.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 的创建、添加和销毁。Toast 组件的开发者体验应该是最小化且易于上手的,就像这样
import Toast from './toast.js'
Toast('My first toast')
创建 Toast 组和 Toast
当从 JavaScript 加载 Toast 模块时,它必须创建一个 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
}
init()
函数在模块内部调用,将该元素存储为 Toaster
const Toaster = init()
Toast HTML 元素的创建通过 createToast()
函数完成。该函数需要一些 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)
}
当添加第一个 Toast 时,Toaster.appendChild(toast)
会向页面添加一个 Toast,从而触发 CSS 动画:动画进入、等待 3s
、动画退出。当存在现有 Toast 时,会调用 flipToast()
,采用 FLIP 技术,由 Paul Lewis 提出。这个想法是计算容器在添加新 Toast 之前和之后的position的差异。可以将其想象为标记 Toaster 现在的位置、它将要去的位置,然后从它过去的位置动画到它现在的位置。
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 Animations API 从旧位置动画容器。
将所有 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
promise 是否完成。allSettled
为我们完成了这项工作,一旦其所有 promise 都已兑现,它就会自行解析为完成状态。使用 await Promise.allSettled()
意味着下一行代码可以放心地删除该元素,并假设 Toast 已完成其生命周期。最后,调用 resolve()
会兑现高级 Toast promise,以便开发者可以在 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 上构建的所有方法。创建一个演示,在 Twitter 上给我发链接,我会将其添加到下面的社区混音部分!
社区混音
- @_developit 使用 HTML/CSS/JS:演示和代码
- Joost van der Schee 使用 HTML/CSS/JS:演示和代码