Создание компонента всплывающей подсказки

Базовый обзор того, как создать адаптивный по цвету и доступный пользовательский элемент подсказки.

В этой публикации я хочу поделиться своими мыслями о том, как создать адаптивный к цвету и доступный элемент <tool-tip> . Попробуйте демо-версию и посмотрите исходный код !

Подсказка отображается с использованием различных примеров и цветовых схем.

Если вы предпочитаете видео, вот версия этого поста на YouTube:

Обзор

Подсказка — это немодальное, неблокирующее и неинтерактивное наложение, содержащее дополнительную информацию для пользовательских интерфейсов. По умолчанию она скрыта и становится видимой при наведении курсора на связанный с ней элемент или фокусе на нём. Подсказку нельзя выбрать или использовать напрямую. Подсказки не заменяют метки или другую важную информацию; пользователь должен иметь возможность полностью выполнить свою задачу без подсказки.

Делайте следующее: всегда маркируйте свои входные данные.
Не стоит полагаться на подсказки вместо меток.

Подсказка-переключатель против подсказки

Как и многие компоненты, существуют различные описания всплывающей подсказки, например, в MDN , WAI ARIA , Sarah Higley и Inclusive Components . Мне нравится разделение между всплывающей подсказкой и подсказкой-переключателем. Подсказка должна содержать неинтерактивную дополнительную информацию, в то время как подсказка-переключатель может содержать интерактивность и важную информацию. Основная причина такого разделения — доступность: как пользователи должны переходить к всплывающему окну и получать доступ к информации и кнопкам внутри. Подсказки-переключатели быстро становятся сложными.

Ниже представлено видео с подсказкой-переключателем с сайта Designcember ; интерактивное наложение, которое пользователь может закрепить в открытом виде и изучить, а затем закрыть с помощью кнопки «Открыть» или клавиши Esc:

Это задание по созданию графического интерфейса пошло по пути подсказки, пытаясь сделать практически все с помощью CSS, и вот как это сделать.

Разметка

Я решил использовать пользовательский элемент <tool-tip> . Авторам не нужно превращать пользовательские элементы в веб-компоненты, если они этого не хотят. Браузер будет воспринимать <foo-bar> как <div> . Пользовательский элемент можно рассматривать как имя класса с меньшей специфичностью. JavaScript не используется.

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

Это похоже на div с текстом внутри. Мы можем подключиться к дереву доступности совместимых программ чтения с экрана, добавив [role="tooltip"] .

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

Теперь программы чтения с экрана распознают это как подсказку. Видите в следующем примере, как первый элемент ссылки распознаётся как элемент подсказки в своём дереве, а второй — нет? У второго элемента такой роли нет. В разделе стилей мы улучшим это древовидное представление.

Скриншот дерева специальных возможностей Chrome DevTools, представляющего HTML. Показывает ссылку с текстом «top»; есть подсказка: «Эй, подсказка!», на которую можно установить фокус. Внутри ссылки находится статический текст «top» и элемент подсказки.

Далее нам нужно сделать подсказку нефокусируемой. Если программа чтения с экрана не понимает роль подсказки, она позволит пользователям фокусироваться на <tool-tip> для чтения содержимого, что не требуется для пользовательского опыта. Программы чтения с экрана добавят содержимое к родительскому элементу, и, таким образом, фокус для доступа к нему не требуется. Здесь мы можем использовать inert , чтобы гарантировать, что пользователи случайно не найдут это содержимое подсказки в потоке вкладок:

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

Ещё один скриншот дерева специальных возможностей Chrome DevTools, на этот раз отсутствует элемент всплывающей подсказки.

Затем я решил использовать атрибуты в качестве интерфейса для указания положения подсказки. По умолчанию все элементы <tool-tip> занимают позицию «сверху», но её положение можно настроить для элемента, добавив tip-position :

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

Скриншот ссылки с подсказкой справа: «Подсказка».

Я предпочитаю использовать атрибуты вместо классов для таких вещей, чтобы <tool-tip> не мог занимать несколько позиций одновременно. Может быть только одна позиция или ни одной.

Наконец, разместите элементы <tool-tip> внутри элемента, для которого вы хотите создать подсказку. Здесь я делюсь alt текстом со зрячими пользователями, размещая изображение и <tool-tip> внутри элемента <picture> :

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

Скриншот изображения с подсказкой «Графический интерфейс бросает вызов логотипу черепа».

Здесь я размещаю <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. Наконец, убедитесь, что любое движение уменьшено, если пользователь указал предпочтение уменьшению движения.

Наша цель — предоставление дополнительных сообщений по запросу. Зрячий пользователь мыши или клавиатуры может навести курсор, чтобы увидеть сообщение, читая его глазами. Незрячий пользователь программы чтения с экрана может сфокусироваться, чтобы увидеть сообщение, и воспринять его вслух через свой инструмент.

Скриншот VoiceOver в MacOS, читающего ссылку с подсказкой

В предыдущем разделе мы рассмотрели дерево доступности, роль подсказок и инертность. Осталось только протестировать его и убедиться, что пользователь правильно отображает подсказку. При тестировании неясно, какая часть звукового сообщения является подсказкой. Это также можно заметить при отладке дерева доступности: текст ссылки «top» без задержек сочетается с фразой «Смотри, подсказки!». Программа чтения с экрана не прерывает текст и не идентифицирует его как содержимое подсказки.

Скриншот дерева специальных возможностей Chrome DevTools, где текст ссылки гласит: «Вверх. Эй, подсказка!».

Добавьте псевдоэлемент, предназначенный только для чтения с экрана, к <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 DevTools, где текст ссылки имеет улучшенную формулировку: «вверх; Имеет подсказку: Эй, подсказка!».

Теперь, когда пользователь программы чтения с экрана наводит фокус на ссылку, появляется надпись «top» и небольшая пауза, а затем объявляется «has tooltip: look, tooltips». Это даёт пользователю программы чтения с экрана пару полезных UX-подсказок. Задержка создаёт удобное разделение между текстом ссылки и подсказкой. Кроме того, когда объявляется «has tooltip», пользователь программы чтения с экрана может легко отменить это, если уже слышал это раньше. Это очень напоминает быстрое наведение и снятие курсора, поскольку вы уже видели дополнительное сообщение. Это выглядит как приятное UX-паритет.

Стили

Элемент <tool-tip> будет дочерним по отношению к элементу, для которого он представляет дополнительное сообщение, поэтому сначала давайте разберёмся с основами эффекта наложения. Выведите его из потока документа с помощью position absolute :

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

Если родительский элемент не является контекстом наложения, подсказка будет позиционироваться на ближайшем к нему, что нам не нужно. В блоке появился новый селектор, который может помочь :has() :

Browser Support

  • Хром: 105.
  • Край: 105.
  • Firefox: 121.
  • Сафари: 15.4.

Source

: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-block или inset-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);
}

Анимация

До сих пор мы только переключали видимость подсказки. В этом разделе мы сначала анимируем непрозрачность для всех пользователей, поскольку это, как правило, безопасный переход с уменьшенным движением. Затем мы анимируем положение преобразования, чтобы подсказка выглядела выезжающей из родительского элемента.

Безопасный и осмысленный переход по умолчанию

Настройте элемент подсказки так, чтобы он менял непрозрачность и трансформировался, как показано ниже:

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() , и этот скрипт можно будет полностью убрать.

Скрипт полифилла выполняет две функции и работает только в том случае, если браузер не поддерживает :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() не поддерживается.

Заключение

Теперь, когда вы знаете, как я это сделал, как бы вы… 🙂 Я с нетерпением жду API popup для упрощения работы с подсказками, верхнего слоя , чтобы избежать борьбы за z-index, и API anchor для лучшего позиционирования элементов в окне. А пока я буду делать подсказки.

Давайте разнообразим наши подходы и изучим все способы развития в Интернете.

Создайте демо, пришлите мне ссылку в Твиттер , и я добавлю ее в раздел ремиксов сообщества ниже!

Ремиксы сообщества

Пока что здесь нечего смотреть.

Ресурсы