构建提示组件

关于如何构建可自适应颜色且符合无障碍标准的工具提示自定义元素的基础概览。

在这篇博文中,我想分享一下关于如何构建可自适应颜色且易于访问的 <tool-tip> 自定义元素的想法。试用演示版查看源代码

显示了在各种示例和配色方案中正常运行的提示

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

概览

提示是一种非模态、非阻塞、非交互式叠加层,包含用户界面的补充信息。默认情况下处于隐藏状态,当关联的元素被悬停或聚焦时,会变为非隐藏状态。无法直接选择或互动提示。提示不应取代标签或其他高价值信息,用户应能够在没有提示的情况下完全完成任务。

正确做法:始终为输入内容添加标签。
错误做法:依赖提示,而不是标签

切换提示与工具提示

与许多组件一样,工具提示的定义也各不相同,例如在 MDNWAI ARIASarah HigleyInclusive Components 中。我喜欢提示和切换提示之间的分隔。提示框应包含非互动式补充信息,而切换提示框可以包含互动元素和重要信息。造成这种差异的主要原因是无障碍功能,即用户应如何导航到弹出式窗口并访问其中的信息和按钮。切换提示很快就会变得复杂。

以下视频展示了 Designcember 网站上的切换提示;这是一个具有互动性的叠加层,用户可以将其固定打开并进行探索,然后通过轻触关闭或按 Esc 键将其关闭:

此 GUI 挑战赛采用了提示框的方式,几乎完全使用 CSS 来实现,下面介绍如何构建它。

Markup

我选择使用自定义元素 <tool-tip>。如果作者不想将自定义元素制作成 Web 组件,则无需这样做。浏览器会将 <foo-bar> 视为 <div>。您可以将自定义元素视为特异性较低的类名。不涉及 JavaScript。

<tool-tip>A tooltip</tool-tip>

这就像一个包含一些文本的 div。我们可以通过添加 [role="tooltip"] 来与支持的屏幕阅读器的无障碍树相关联。

<tool-tip role="tooltip">A tooltip</tool-tip>

现在,对于屏幕阅读器,它会被识别为提示。在以下示例中,您可以看到第一个链接元素在其树中有一个可识别的提示元素,而第二个链接元素没有。第二个用户没有该角色。在样式部分,我们将改进此树状视图。

Chrome 开发者工具无障碍树的屏幕截图,表示 HTML。显示一个链接,其中包含文本“顶部;有提示:嘿,这是提示!”且可聚焦。其中包含“顶部”静态文本和一个提示元素。

接下来,我们需要让提示不可聚焦。如果屏幕阅读器无法识别提示角色,它会允许用户聚焦 <tool-tip> 以读取内容,而用户体验并不需要这样做。屏幕阅读器会将内容附加到父元素,因此无需聚焦即可访问。在此处,我们可以使用 inert 来确保没有任何用户会在其标签页流程中意外发现此提示内容:

<tool-tip inert role="tooltip">A tooltip</tool-tip>

Chrome 开发者工具无障碍功能树的另一张屏幕截图,这次缺少了提示元素。

然后,我选择使用属性作为指定提示位置的接口。默认情况下,所有 <tool-tip> 都会采用“顶部”位置,但您可以通过添加 tip-position 来自定义元素的位置:

<tool-tip role="tooltip" tip-position="right ">A tooltip</tool-tip>

屏幕截图:一个链接,右侧有一个提示,内容为“A tooltip”。

我倾向于使用属性而不是类来处理此类情况,这样 <tool-tip> 就不会同时分配到多个位置。只能有一个或没有。

最后,将 <tool-tip> 元素放置在您希望提供提示的元素内。在此示例中,我通过将图片和 <tool-tip> 放置在 <picture> 元素内,与视力正常的用户分享 alt 文本:

<picture>
  <img alt="The GUI Challenges skull logo" width="100" src="...">
  <tool-tip role="tooltip" tip-position="bottom">
    The <b>GUI Challenges</b> skull logo
  </tool-tip>
</picture>

一张图片的屏幕截图,其中包含一个显示“GUI Challenges 头骨徽标”的工具提示。

下面是将 <tool-tip> 放置在 <abbr> 元素内的示例:

<p>
  The <abbr>HTML <tool-tip role="tooltip" tip-position="top">Hyper Text Markup Language</tool-tip></abbr> abbr element.
</p>

屏幕截图:一段文字,其中缩写词 HTML 下划线,上方有一个提示,显示“Hyper Text Markup Language”。

无障碍

由于我选择构建的是提示框,而不是切换提示框,因此此部分要简单得多。首先,我来简要说明一下我们期望的用户体验:

  1. 在空间有限或界面杂乱的情况下,隐藏补充消息。
  2. 当用户悬停、聚焦或使用触控与元素互动时,显示消息。
  3. 当悬停、聚焦或触摸结束时,再次隐藏消息。
  4. 最后,确保在用户指定了减少动画偏好设置的情况下,减少所有动画。

我们的目标是按需提供补充消息。使用鼠标或键盘的视力正常的用户可以将鼠标悬停在消息上以显示消息,然后用眼睛阅读消息。不使用屏幕阅读器的用户可以聚焦来显示消息,并通过工具以音频方式接收消息。

屏幕截图:MacOS VoiceOver 正在朗读带有提示的链接

在上一部分中,我们介绍了无障碍功能树、提示角色和 inert,剩下的就是对其进行测试,并验证用户体验是否能恰当地向用户显示提示消息。在测试时,不清楚可听消息的哪个部分是提示。在调试时,也可以在无障碍功能树中看到这种情况,“顶部”的链接文字与“看,提示!”连在一起,没有停顿。屏幕阅读器不会中断或将文本识别为提示内容。

Chrome 开发者工具无障碍树的屏幕截图,其中链接文字显示为“top Hey, a tooltip!”。

<tool-tip> 添加仅供屏幕阅读器使用的伪元素,这样我们就可以为盲人用户添加自己的提示文本。

&::before {
  content: "; Has tooltip: ";
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

您可以在下方看到更新后的无障碍功能树,其中现在在链接文本后添加了分号,并添加了提示“有提示:”的工具提示。

Chrome DevTools 无障碍功能树的更新版屏幕截图,其中链接文字的措辞已改进为“顶部;有提示:嘿,这是一个提示!”。

现在,当屏幕阅读器用户聚焦于该链接时,屏幕阅读器会先读出“顶部”,然后稍作停顿,再读出“有提示:看,提示”。这会为屏幕阅读器用户提供一些不错的用户体验提示。犹豫时间可让链接文字和提示之间有适当的间隔。此外,当系统宣布“有提示”时,屏幕阅读器用户如果之前已经听到过该提示,可以轻松取消。这与快速悬停和取消悬停非常相似,因为您已经看到了补充消息。这感觉像是实现了良好的用户体验对等性。

样式

<tool-tip> 元素将是其表示的补充消息所对应的元素的子元素,因此我们先从叠加效果的基本要素开始。使用 position absolute 将其从文档流中移除:

tool-tip {
  position: absolute;
  z-index: 1;
}

如果父元素不是堆叠上下文,则提示会定位到最近的堆叠上下文,这不是我们想要的。代码块中有一个新的选择器可以提供帮助,即 :has()

Browser Support

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 15.4.

Source

:has(> tool-tip) {
  position: relative;
}

不必过于担心浏览器支持问题。首先,请注意这些提示是补充信息。如果它们不起作用,应该也没问题。其次,在 JavaScript 部分,我们将部署一个脚本来为不支持 :has() 的浏览器提供所需的 polyfill 功能。

接下来,我们让提示成为非互动式,这样它们就不会从父元素窃取指针事件:

tool-tip {
  
  pointer-events: none;
  user-select: none;
}

然后,通过不透明度隐藏提示,以便通过淡入淡出效果过渡提示:

tool-tip {
  opacity: 0;
}

:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
}

:is():has() 在这里发挥了重要作用,使包含父元素的 tool-tip 能够感知用户互动,从而切换子工具提示的可见性。鼠标用户可以悬停,键盘和屏幕阅读器用户可以聚焦,触摸用户可以点按。

在为视力正常的用户实现显示和隐藏叠加层的功能后,接下来需要添加一些样式,以便设置主题、定位并将三角形形状添加到气泡中。以下样式开始使用自定义属性,在目前的基础上添加阴影、排版和颜色,使其看起来像一个悬浮提示:

深色模式下悬浮在链接“block-start”上的工具提示的屏幕截图。

tool-tip {
  --_p-inline: 1.5ch;
  --_p-block: .75ch;
  --_triangle-size: 7px;
  --_bg: hsl(0 0% 20%);
  --_shadow-alpha: 50%;

  --_bottom-tip: conic-gradient(from -30deg at bottom, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) bottom / 100% 50% no-repeat;
  --_top-tip: conic-gradient(from 150deg at top, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) top / 100% 50% no-repeat;
  --_right-tip: conic-gradient(from -120deg at right, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) right / 50% 100% no-repeat;
  --_left-tip: conic-gradient(from 60deg at left, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) left / 50% 100% no-repeat;

  pointer-events: none;
  user-select: none;

  opacity: 0;
  transform: translateX(var(--_x, 0)) translateY(var(--_y, 0));
  transition: opacity .2s ease, transform .2s ease;

  position: absolute;
  z-index: 1;
  inline-size: max-content;
  max-inline-size: 25ch;
  text-align: start;
  font-size: 1rem;
  font-weight: normal;
  line-height: normal;
  line-height: initial;
  padding: var(--_p-block) var(--_p-inline);
  margin: 0;
  border-radius: 5px;
  background: var(--_bg);
  color: CanvasText;
  will-change: filter;
  filter:
    drop-shadow(0 3px 3px hsl(0 0% 0% / var(--_shadow-alpha)))
    drop-shadow(0 12px 12px hsl(0 0% 0% / var(--_shadow-alpha)));
}

/* create a stacking context for elements with > tool-tips */
:has(> tool-tip) {
  position: relative;
}

/* when those parent elements have focus, hover, etc */
:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
  transition-delay: 200ms;
}

/* prepend some prose for screen readers only */
tool-tip::before {
  content: "; Has tooltip: ";
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

/* tooltip shape is a pseudo element so we can cast a shadow */
tool-tip::after {
  content: "";
  background: var(--_bg);
  position: absolute;
  z-index: -1;
  inset: 0;
  mask: var(--_tip);
}

/* top tooltip styles */
tool-tip:is(
  [tip-position="top"],
  [tip-position="block-start"],
  :not([tip-position]),
  [tip-position="bottom"],
  [tip-position="block-end"]
) {
  text-align: center;
}

主题调整

由于提示的文字颜色是通过系统关键字 CanvasText 从网页继承的,因此提示只需管理少量颜色。此外,由于我们已创建自定义属性来存储这些值,因此只需更新这些自定义属性,其余部分由主题处理:

@media (prefers-color-scheme: light) {
  tool-tip {
    --_bg: white;
    --_shadow-alpha: 15%;
  }
}

工具提示的浅色版本和深色版本的并排屏幕截图。

对于浅色主题,我们会将背景调整为白色,并通过调整阴影的不透明度来大幅减弱阴影效果。

从右向左

为了支持从右向左的阅读模式,自定义属性会将文档方向的值分别存储为 -1 或 1。

tool-tip {
  --isRTL: -1;
}

tool-tip:dir(rtl) {
  --isRTL: 1;
}

这可用于辅助定位提示:

tool-tip[tip-position="top"]) {
  --_x: calc(50% * var(--isRTL));
}

以及协助确定三角形的位置:

tool-tip[tip-position="right"]::after {
  --_tip: var(--_left-tip);
}

tool-tip[tip-position="right"]:dir(rtl)::after {
  --_tip: var(--_right-tip);
}

最后,还可以用于对 translateX() 进行逻辑转换:

--_x: calc(var(--isRTL) * -3px * -1);

提示位置

使用 inset-blockinset-inline 属性以逻辑方式定位提示,从而同时处理物理提示位置和逻辑提示位置。以下代码展示了如何为从左到右和从右到左两种方向设置四种位置的样式。

顶部对齐和块起始位置对齐

一张屏幕截图,显示了从左到右的顶部位置与从右到左的顶部位置之间的放置差异。

tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position])) {
  inset-inline-start: 50%;
  inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-size));
  --_x: calc(50% * var(--isRTL));
}

tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position]))::after {
  --_tip: var(--_bottom-tip);
  inset-block-end: calc(var(--_triangle-size) * -1);
  border-block-end: var(--_triangle-size) solid transparent;
}

右对齐和行尾对齐

屏幕截图:显示了从左到右的右侧位置与从右到左的内嵌末尾位置之间的放置位置差异。

tool-tip:is([tip-position="right"], [tip-position="inline-end"]) {
  inset-inline-start: calc(100% + var(--_p-inline) + var(--_triangle-size));
  inset-block-end: 50%;
  --_y: 50%;
}

tool-tip:is([tip-position="right"], [tip-position="inline-end"])::after {
  --_tip: var(--_left-tip);
  inset-inline-start: calc(var(--_triangle-size) * -1);
  border-inline-start: var(--_triangle-size) solid transparent;
}

tool-tip:is([tip-position="right"], [tip-position="inline-end"]):dir(rtl)::after {
  --_tip: var(--_right-tip);
}

底部对齐和块末尾对齐

一张屏幕截图,显示了从左到右的底部位置与从右到左的块末尾位置之间的放置差异。

tool-tip:is([tip-position="bottom"], [tip-position="block-end"]) {
  inset-inline-start: 50%;
  inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-size));
  --_x: calc(50% * var(--isRTL));
}

tool-tip:is([tip-position="bottom"], [tip-position="block-end"])::after {
  --_tip: var(--_top-tip);
  inset-block-start: calc(var(--_triangle-size) * -1);
  border-block-start: var(--_triangle-size) solid transparent;
}

左对齐和 inline-start 对齐

屏幕截图:显示了从左到右的左侧位置与从右到左的内联起始位置之间的放置差异。

tool-tip:is([tip-position="left"], [tip-position="inline-start"]) {
  inset-inline-end: calc(100% + var(--_p-inline) + var(--_triangle-size));
  inset-block-end: 50%;
  --_y: 50%;
}

tool-tip:is([tip-position="left"], [tip-position="inline-start"])::after {
  --_tip: var(--_right-tip);
  inset-inline-end: calc(var(--_triangle-size) * -1);
  border-inline-end: var(--_triangle-size) solid transparent;
}

tool-tip:is([tip-position="left"], [tip-position="inline-start"]):dir(rtl)::after {
  --_tip: var(--_left-tip);
}

动画

到目前为止,我们只切换了提示的显示状态。在本部分中,我们将首先为所有用户设置不透明度动画,因为这是一种通常安全的减少运动过渡效果。然后,我们将为转换位置添加动画效果,使工具提示看起来像是从父元素中滑出。

安全且有意义的默认过渡

设置提示元素的样式,以实现不透明度和转换效果,如下所示:

tool-tip {
  opacity: 0;
  transform: translateX(var(--_x, 0)) translateY(var(--_y, 0));
  transition: opacity .2s ease, transform .2s ease;
}

:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
  transition-delay: 200ms;
}

为过渡效果添加动画

对于提示可能显示的每个侧边,如果用户接受动画,请稍微调整 translateX 属性的位置,使其从以下位置移动一小段距离:

@media (prefers-reduced-motion: no-preference) {
  :has(> tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position]))):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_y: 3px;
  }

  :has(> tool-tip:is([tip-position="right"], [tip-position="inline-end"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_x: -3px;
  }

  :has(> tool-tip:is([tip-position="bottom"], [tip-position="block-end"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_y: -3px;
  }

  :has(> tool-tip:is([tip-position="left"], [tip-position="inline-start"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_x: 3px;
  }
}

请注意,这是在设置“out”状态,因为“in”状态为 translateX(0)

JavaScript

在我看来,JavaScript 是可选的。这是因为,在界面中完成任务时,无需阅读这些工具提示。因此,如果工具提示完全失败,应该也没什么大不了的。这也意味着我们可以将提示视为逐步增强的。最终,所有浏览器都将支持 :has(),届时此脚本将完全消失。

polyfill 脚本会执行两项操作,并且仅当浏览器不支持 :has() 时才会执行这些操作。首先,检查 :has() 支持情况:

if (!CSS.supports('selector(:has(*))')) {
  // do work
}

接下来,找到 <tool-tip> 的父元素,并为其指定一个类名以供使用:

if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('tool-tip').forEach(tooltip =>
    tooltip.parentNode.classList.add('has_tool-tip'))
}

接下来,注入一组使用该类名的样式,模拟 :has() 选择器的完全相同的行为:

if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('tool-tip').forEach(tooltip =>
    tooltip.parentNode.classList.add('has_tool-tip'))

  let styles = document.createElement('style')
  styles.textContent = `
    .has_tool-tip {
      position: relative;
    }
    .has_tool-tip:is(:hover, :focus-visible, :active) > tool-tip {
      opacity: 1;
      transition-delay: 200ms;
    }
  `
  document.head.appendChild(styles)
}

这样就完成了,现在,如果不支持 :has(),所有浏览器都会愉快地显示提示。

总结

现在您已经了解了我的做法,您会怎么做呢?🙂 我非常期待 popup API(可让切换提示更易于实现)、顶层(可避免 z-index 冲突)和 anchor API(可更好地定位窗口中的内容)。在此之前,我将制作提示。

让我们丰富方法,了解在 Web 上构建的所有方式。

制作演示视频,通过 Twitter 向我发送链接,我会将其添加到下方的社区混音部分!

社区混音作品

此处尚无可显示的内容。

资源