构建提示组件

简要介绍了如何构建颜色自适应且符合无障碍标准的提示自定义元素。

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

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

如果你更喜欢视频,可以参阅此博文的 YouTube 版本:

概览

提示是指非模态、非阻塞、非交互式叠加层,其中包含界面的补充信息。它默认处于隐藏状态,当用户悬停或聚焦于关联的元素时,才会显示。您无法直接选择或与提示框互动。提示不能替代标签或其他高价值信息,用户应该能够在不使用提示的情况下完成任务。

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

切换提示与提示

与许多组件一样,关于提示的定义也各不相同,例如 MDNWAI ARIASarah Higuley包容性组件。我喜欢将提示和切换提示分开。提示应包含非交互式补充信息,而切换开关提示可以包含互动性和重要信息。分隔的主要原因在于可访问性,用户应如何导航到弹出式窗口并访问其中的信息和按钮。切换提示会很快变得复杂。

以下视频展示了 Designcember 网站上的切换提示;这是一种交互式叠加层,用户可以将其固定打开并进行探索,然后使用轻触关闭或按下退出键将其关闭:

本 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>

现在,屏幕阅读器会将其识别为提示。请参阅以下示例,了解第一个链接元素的树中包含已识别的提示信息元素,而第二个链接元素的树中不包含。第二个联系人没有这个角色。在“样式”部分,我们将改进此树状视图。

一张屏幕截图,显示了表示 HTML 的 Chrome DevTools 无障碍功能树。显示带有文本“top ; Has tooltip: Hey, a tooltip!”且可聚焦的链接。其中包含“top”的静态文本和一个提示信息元素。

接下来,我们需要使提示不可聚焦。如果屏幕阅读器不理解提示工具的角色,则会允许用户将焦点置于 <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>

显示链接的屏幕截图,右侧显示“提示”字样的提示。

对于此类情况,我倾向于使用属性而非类,以便 <tool-tip> 无法同时被分配多个位置。只能有一个或没有。

最后,将 <tool-tip> 元素放置在您希望为其提供提示的元素内。在这里,我通过在 <picture> 元素中放置图片和 <tool-tip>,与视力正常的用户分享 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>

一张图片的屏幕截图,其中显示了一条提示“The GUI Challenges skull logo”。

在这里,我将 <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 的段落,以及上面显示“超文本标记语言”的提示。

无障碍

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

  1. 在空间受限或界面杂乱的情况下,请隐藏补充消息。
  2. 当用户将鼠标悬停在某个元素上、聚焦于某个元素或使用轻触与该元素互动时,显示该消息。
  3. 当悬停、聚焦或触摸结束时,再次隐藏消息。
  4. 最后,如果用户已指定减少动作的偏好,则确保减少任何动作。

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

MacOS VoiceOver 读取带有提示的链接的屏幕截图

在上一节中,我们介绍了无障碍树、提示信息角色和无效状态,接下来要做的是测试它,并验证用户体验是否会适当地向用户显示提示信息。经过测试,我们发现无法确定音频消息的哪个部分是提示。在无障碍树中进行调试时也会看到这种效果,链接文本“top”会毫无疑问地与“Look, tooltips!”(查看,提示!)一起运行。屏幕阅读器不会换行或将文本识别为提示内容。

Chrome DevTools 无障碍功能树的屏幕截图,其中链接文字显示为“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;
}

您可以在下方看到经过更新的无障碍功能树,其中链接文本后面现在有一个英文分号,并且有一个提示“Has tooltip:”(有提示)。

Chrome 开发者工具无障碍功能树的更新屏幕截图,其中链接文本改进了表述“top ; Has tooltip: Hey, a tooltip!”。

现在,当屏幕阅读器用户将焦点放在链接上时,屏幕阅读器会读出“顶部”,稍作停顿,然后读出“有提示:看,提示”。这会为屏幕阅读器用户提供一些实用的用户体验提示。这种犹豫效果可在链接文本和提示信息之间形成良好的分隔。此外,当屏幕阅读器用户读出“具有提示”时,如果屏幕阅读器用户已经听到过该提示,他们可以轻松将其取消。正如您已经看到了补充消息,这很会让人联想到快速悬停和停止悬停。这感觉像是一致的用户体验。

样式

<tool-tip> 元素将是其代表的补充消息所属元素的子元素,因此我们先从叠加效果的要点入手。使用 position absolute 将其从文档流程中移除:

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

如果父级不是堆叠上下文,则提示将定位到最近的堆叠上下文,这不是我们想要的。该代码块上有一个新的选择器 :has(),可以帮助您解决问题:

浏览器支持

  • Chrome:105。
  • 边缘:105。
  • Firefox:121.
  • Safari:15.4。

来源

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

不要过于担心浏览器支持问题。首先,请记住这些提示是补充的如果不起作用,也无妨。其次,在“JavaScript”部分,我们将部署一个脚本,以便为不支持 :has() 的浏览器实现我们需要的功能。

接下来,让我们让提示不具互动性,以免它们从父元素窃取指针事件:

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

右对齐和内嵌端对齐

一张屏幕截图,显示了从左到右的右侧位置与从右到左的内嵌-end 位置之间的展示位置差异。

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

左对齐和内嵌-开始对齐

一张屏幕截图,显示了从左到右的左侧位置与从右到左的内嵌起始位置之间的展示位置差异。

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 上构建的所有方式。

制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!

社区混剪作品

此处尚无任何可显示的内容。

资源