构建提示组件

基本概述了如何构建颜色自适应且可访问的提示自定义元素。

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

系统会显示一条提示,适用于各种示例和配色方案

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

概览

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

正确做法:请务必为您的输入添加标签。
错误做法:依靠提示,而不是标签

切换提示与提示

与许多组件一样,对提示的描述也有不同的描述,例如在 MDNWAI ARIASarah Higley包容性组件中。我喜欢将提示和切换开关区分开来。提示应包含非交互式补充信息,而切换开关提示可以包含互动性和重要信息。出现这种拆分的主要原因是无障碍功能,即用户应如何导航到弹出式窗口以及访问其中的信息和按钮。切换提示会迅速变得复杂。

以下是来自 Designcember 网站的切换提示视频;一个具有互动性的叠加层,用户可以固定打开和探索该叠加层,然后通过轻关闭键或 Esc 键关闭:

此 GUI 挑战沿着提示进行,希望借助 CSS 完成几乎所有任务,以下是具体的构建方法。

Markup

我选择了使用自定义元素“<tool-tip>”。作者就不需要将自定义元素制作成网络组件。浏览器会将 <foo-bar> 视为 <div>。您可以将自定义元素视为具有较低特异性的类名。不涉及 JavaScript。

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

这就像内含一些文本的 div。我们可以通过添加 [role="tooltip"] 来绑定到支持屏幕阅读器的无障碍功能树。

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

现在,对于屏幕阅读器,它被识别为提示。在下面的示例中,第一个 link 元素在其树中具有可识别的提示元素,而第二个元素却没有?第二个没有这个角色。在“样式”部分中,我们将对这个树状视图进行改进。

表示 HTML 的 Chrome 开发者工具无障碍树的屏幕截图。显示包含可聚焦文本“top ; Has tooltip: Hey, a tooltip!”的链接。其内部是静态文本“top”和 tooltip 元素。

接下来,我们需要提示无法聚焦。如果屏幕阅读器不理解提示角色,它会允许用户聚焦 <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> 元素放置在要为其提供提示的元素内部。在这里,我通过在 <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>

图片的屏幕截图,提示内容为“GUI 挑战骷髅徽标”。

在这里,我在 <abbr> 元素内放置了一个 <tool-tip>

<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 开发者工具无障碍功能树的屏幕截图,其中链接文本显示“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!”。

现在,当屏幕阅读器用户聚焦于链接时,它会说“顶部”并稍作暂停,然后读出“has tooltip: look, tooltips”。这可以为屏幕阅读器用户提供几个实用的用户体验提示。犹豫可以很好地分隔链接文本和提示。此外,当系统读出“显示提示”时,如果屏幕阅读器用户之前已经听到过该提示,那么可以轻松地取消提示。这很容易让人联想到快速悬停和取消悬停,因为您已经看过补充消息。感觉跟用户体验差不多。

风格

<tool-tip> 元素将是代表其补充消息的元素的子元素,因此,我们先从叠加效果的基本知识开始。使用 position absolute 可退出文档流程:

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

如果父项不是堆叠上下文,提示工具会将自身定位到最近的上下文,这并不是我们想要的。块上有一个新的选择器 :has() 可以为您提供帮助:

浏览器支持

  • 105
  • 105
  • 121
  • 15.4

来源

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

左对齐和内嵌开头对齐

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

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

动画

到目前为止,我们仅切换了提示的可见性。在本部分中,我们将首先为所有用户添加不透明度动画,因为这通常是一种安全的减少动作过渡。然后,我们将为转换位置添加动画效果,以使提示从父元素滑出。

安全且有意义的默认过渡

设置 tooltip 元素的样式,使其实现过渡不透明度和变形,如下所示:

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

只需创建一个演示,点击 tweet me 链接,我就会将其添加到下方的“社区混剪”部分中!

社区混剪作品

此处尚无可显示的内容。

资源