Créer un composant d'info-bulle

Présentation des principes de base sur la création d'un élément personnalisé d'info-bulle adaptatif et accessible aux couleurs.

Dans cet article, je souhaite partager mes réflexions sur la création d'un élément personnalisé <tool-tip> adaptatif et accessible aux couleurs. Essayez la démonstration et consultez la source.

Une info-bulle s'affiche, illustrant différents exemples et jeux de couleurs.

Si vous préférez la vidéo, voici une version YouTube de ce post:

Présentation

Une info-bulle est une superposition non interactive, non modale et non bloquante qui contient des informations supplémentaires sur les interfaces utilisateur. Il est masqué par défaut et apparaît lorsque l'utilisateur passe la souris sur un élément associé ou le sélectionne. Vous ne pouvez pas sélectionner une info-bulle ni interagir avec elle directement. Les info-bulles ne remplacent pas les étiquettes ou d'autres informations importantes. Un utilisateur doit pouvoir effectuer entièrement sa tâche sans info-bulle.

À faire: étiquetez toujours vos entrées.
À ne pas faire: utiliser des info-bulles plutôt que des libellés

Info-bulle ou info-bulle

Comme pour de nombreux composants, il existe différentes descriptions de ce qu'est une info-bulle, par exemple dans MDN, WAI ARIA, Sarah Higley et Composants inclusifs. J'aime bien la séparation entre les info-bulles et les boutons d'activation/de désactivation. Une info-bulle doit contenir des informations supplémentaires non interactives, tandis qu'une info-bulle peut contenir des éléments interactifs et des informations importantes. La principale raison de la division est l'accessibilité, c'est-à-dire la manière dont les utilisateurs sont censés naviguer vers le pop-up et avoir accès aux informations et aux boutons qu'ils contiennent. Les boutons d'activation/de désactivation deviennent rapidement complexes.

Voici une vidéo d'un bouton d'activation du site Designcember, une superposition avec de l'interactivité qu'un utilisateur peut épingler pour ouvrir et explorer, puis fermer avec une légère fermeture ou la touche Échap:

Ce défi IUG a suivi la voie d'une info-bulle, cherchant à faire presque tout avec CSS. Voici comment le créer.

Markup

J'ai choisi d'utiliser un élément personnalisé <tool-tip>. Les auteurs n'ont pas besoin de transformer des éléments personnalisés en composants Web s'ils ne le souhaitent pas. Le navigateur traitera <foo-bar> comme un <div>. Vous pouvez considérer un élément personnalisé comme un nom de classe moins spécifique. Aucun code JavaScript n'est impliqué.

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

C'est comme un élément div contenant du texte. Nous pouvons lier l'arborescence d'accessibilité des lecteurs d'écran compatibles en ajoutant [role="tooltip"].

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

Pour les lecteurs d'écran, cette info-bulle est désormais reconnue. Voyez dans l'exemple suivant comment le premier élément de lien possède un élément d'info-bulle reconnu dans son arborescence et que le second n'en a pas ? Le deuxième n'a pas ce rôle. Dans la section "Styles", nous allons améliorer cette arborescence.

Capture d&#39;écran de l&#39;arborescence d&#39;accessibilité des outils pour les développeurs Chrome représentant le code HTML. Affiche un lien avec le texte &quot;top&quot; ; contient une info-bulle : &quot;Hé, une info-bulle&quot; qui est sélectionnable. Il contient un texte statique &quot;top&quot; et un élément d&#39;info-bulle.

Ensuite, nous avons besoin que l'info-bulle ne soit pas sélectionnable. Si un lecteur d'écran ne comprend pas le rôle d'info-bulle, les utilisateurs peuvent se concentrer sur <tool-tip> pour en lire le contenu, ce qui n'est pas nécessaire pour l'expérience utilisateur. Les lecteurs d'écran ajoutent le contenu à l'élément parent. Par conséquent, il n'est pas nécessaire de le cibler pour être accessible. Ici, nous pouvons utiliser inert pour nous assurer qu'aucun utilisateur ne trouvera accidentellement le contenu de cette info-bulle dans leur flux d'onglets:

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

Une autre capture d&#39;écran de l&#39;arborescence d&#39;accessibilité des outils pour les développeurs Chrome (cette fois, l&#39;élément d&#39;info-bulle est manquant).

J'ai ensuite choisi d'utiliser des attributs comme interface pour spécifier la position de l'info-bulle. Par défaut, tous les <tool-tip> supposent une position "supérieure", mais la position peut être personnalisée sur un élément en ajoutant tip-position:

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

Capture d&#39;écran d&#39;un lien avec une info-bulle à droite indiquant &quot;A tooltip&quot; (Une info-bulle).

J'ai tendance à utiliser des attributs plutôt que des classes pour ce genre de choses, afin que <tool-tip> ne puisse pas avoir plusieurs positions en même temps. Il ne peut y en avoir qu'un ou aucun.

Enfin, placez les éléments <tool-tip> à l'intérieur de l'élément pour lequel vous souhaitez fournir une info-bulle. Ici, je partage le texte alt avec les utilisateurs voyants en plaçant une image et un <tool-tip> à l'intérieur d'un élément <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>

Capture d&#39;écran d&#39;une image avec une info-bulle indiquant &quot;The GUI Challenges skull logo&quot; (logo crâne des défis liés à l&#39;IUG).

Ici, je place un <tool-tip> dans un élément <abbr>:

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

Capture d&#39;écran d&#39;un paragraphe avec l&#39;acronyme HTML souligné et une info-bulle au-dessus du texte &quot;Hyper Text Markup Language&quot;.

Accessibilité

Comme j'ai choisi de créer des info-bulles et non des info-bulles, cette section est beaucoup plus simple. Tout d'abord, voyons quelle expérience utilisateur nous souhaitons:

  1. Dans les espaces restreints ou les interfaces surchargées, masquez les messages supplémentaires.
  2. Lorsqu'un utilisateur pointe sur un élément, le sélectionne ou utilise le toucher pour interagir avec un élément, affichez le message.
  3. Lorsque vous passez la souris, le curseur ou l'appui à la fin, le message est à nouveau masqué.
  4. Enfin, assurez-vous que tous les mouvements sont réduits si un utilisateur a spécifié une préférence pour les mouvements réduits.

Notre objectif est de proposer un service de messagerie complémentaire à la demande. Un utilisateur voyant une souris ou un clavier peut pointer dessus pour révéler le message et le lire avec ses yeux. Un utilisateur de lecteur d'écran non voyant peut se concentrer pour révéler le message, en le recevant audiblement via son outil.

Capture d'écran de l'application VoiceOver pour macOS, lecture d'un lien avec une info-bulle

Dans la section précédente, nous avons abordé l'arborescence d'accessibilité, le rôle d'info-bulle et "inert". Il ne reste plus qu'à la tester et à vérifier que l'expérience utilisateur révèle correctement le message de l'info-bulle à l'utilisateur. Lors des tests, il n'est pas facile de déterminer dans quelle partie du message audible une info-bulle. Vous pouvez également le voir lors du débogage dans l'arborescence d'accessibilité. Le texte du lien "top" est exécuté ensemble, sans hésitation, avec "Look, tooltips!" (Regardez, info-bulles). Le lecteur d'écran ne casse pas et n'identifie pas le texte en tant que contenu d'info-bulle.

Capture d&#39;écran de l&#39;arborescence d&#39;accessibilité des outils pour les développeurs Chrome, où le texte du lien indique &quot;top Hey, a tooltip!&quot;.

Ajoutez un pseudo-élément de type lecteur d'écran à <tool-tip> et nous pouvons ajouter notre propre texte de requête pour les utilisateurs non voyants.

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

Vous pouvez voir ci-dessous l'arborescence d'accessibilité mise à jour, qui comporte désormais un point-virgule après le texte du lien et une invite pour l'info-bulle "Comporte une info-bulle: ".

Capture d&#39;écran mise à jour de l&#39;arborescence d&#39;accessibilité des outils pour les développeurs Chrome, où la formulation du texte du lien a été améliorée : &quot;top ; Has tooltip: Hey, a tooltip!&quot;.

Désormais, lorsqu'un utilisateur de lecteur d'écran sélectionne le lien, il indique "top" (haut) et fait une petite pause, puis annonce "has tooltip: look, tooltips" ( contient une info-bulle : regardez, info-bulles). Cela donne au lecteur d'écran quelques conseils intéressants sur l’expérience utilisateur. L'hésitation permet de séparer clairement le texte du lien et l'info-bulle. De plus, lorsqu'une info-bulle est annoncée, un utilisateur de lecteur d'écran peut facilement l'annuler s'il l'a déjà entendue. Cela vous rappelle de survoler et de relancer rapidement le point de la souris, comme vous l'avez déjà vu dans le message supplémentaire. C'était une belle parité UX.

Styles

L'élément <tool-tip> sera un enfant de l'élément pour lequel il représente les messages supplémentaires. Commençons donc par les éléments essentiels de l'effet de superposition. Retirez-le du flux de documents avec position absolute:

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

Si le parent n'est pas un contexte d'empilement, l'info-bulle se positionne sur le contexte le plus proche, ce qui n'est pas ce que nous souhaitons. Un nouveau sélecteur sur le bloc peut vous aider, :has():

Navigateurs pris en charge

  • 105
  • 105
  • 121
  • 15,4

Source

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

Ne vous préoccupez pas trop de la compatibilité du navigateur. Tout d'abord, rappelez-vous que ces info-bulles sont complémentaires. Si elles ne fonctionnent pas, cela ne devrait pas poser de problème. Ensuite, dans la section JavaScript, nous allons déployer un script pour émuler les fonctionnalités dont nous avons besoin pour les navigateurs non compatibles avec :has().

À présent, rendons les info-bulles non interactives pour qu'elles ne volent pas d'événements de pointeur à leur élément parent:

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

Ensuite, masquez l'info-bulle avec l'option "Opacité" afin de pouvoir la faire passer avec un fondu enchaîné:

tool-tip {
  opacity: 0;
}

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

:is() et :has() font le plus gros du travail, en faisant en sorte que tool-tip contenant des éléments parents soit conscient de l'interactivité de l'utilisateur et permet d'activer/de désactiver la visibilité d'une info-bulle enfant. Les utilisateurs d'une souris peuvent pointer, les utilisateurs d'un clavier et d'un lecteur d'écran peuvent effectuer la mise au point, et les utilisateurs tactiles peuvent appuyer.

Maintenant que la superposition d'affichage et de masquage fonctionne pour les utilisateurs voyants, il est temps d'ajouter des styles pour la thématisation, le positionnement et l'ajout de la forme triangulaire à la bulle. Les styles suivants commencent à utiliser des propriétés personnalisées, en s'appuyant sur l'endroit où nous en sommes, mais en ajoutant des ombres, une typographie et des couleurs pour ressembler à une info-bulle flottante:

Capture d&#39;écran de l&#39;info-bulle en mode sombre, flottant au-dessus du lien &quot;block-start&quot;.

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

Ajustements du thème

L'info-bulle n'a que quelques couleurs à gérer, car la couleur du texte est héritée de la page via le mot clé système CanvasText. De plus, comme nous avons créé des propriétés personnalisées pour stocker les valeurs, nous ne pouvons mettre à jour que ces propriétés personnalisées et laisser le thème gérer le reste:

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

Capture d&#39;écran côte à côte des versions claire et sombre de l&#39;info-bulle.

Pour le thème clair, nous adaptons l'arrière-plan au blanc et réglons l'opacité des ombres dans une valeur bien moindre.

De droite à gauche

Pour prendre en charge les modes de lecture de droite à gauche, une propriété personnalisée stocke la valeur de l'orientation du document dans une valeur de -1 ou 1, respectivement.

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

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

Cela peut vous aider à positionner l'info-bulle:

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

Cette fonction vous assiste également à la position du triangle:

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

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

Enfin, il peut également être utilisé pour les transformations logiques sur translateX():

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

Positionnement de l'info-bulle

Positionnez l'info-bulle de manière logique avec les propriétés inset-block ou inset-inline pour gérer à la fois sa position physique et logique. Le code suivant montre comment chacune des quatre positions est stylisée pour les directions de gauche à droite et de droite à gauche.

Alignement en haut et au début du bloc

Capture d&#39;écran montrant la différence de placement entre les positions supérieure gauche et droite et les positions supérieures de droite à gauche

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

Alignement à droite et aux extrémités intégrées

Capture d&#39;écran montrant la différence d&#39;emplacement entre les positions de gauche à droite et les positions alignées de droite à gauche.

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

Alignement en bas et en fin de bloc

Capture d&#39;écran montrant la différence de placement entre la position inférieure de gauche à droite et la position de fin de bloc de droite à gauche.

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

Alignement à gauche et début aligné

Capture d&#39;écran montrant la différence d&#39;emplacement entre les positions de gauche à droite et de début de droite à gauche.

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

Animation

Pour le moment, nous n'avons modifié que la visibilité de l'info-bulle. Dans cette section, nous allons d'abord animer l'opacité pour tous les utilisateurs, car il s'agit d'une transition de mouvement réduit généralement sûre. Nous animerons ensuite la position de transformation pour que l'info-bulle semble sortir de l'élément parent.

Une transition par défaut sûre et significative

Appliquez un style à l'élément d'info-bulle de sorte à modifier l'opacité et la transformation, comme suit:

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

Ajouter du mouvement à la transition

Pour chacun des côtés, une info-bulle peut apparaître. Si l'utilisateur accepte le mouvement, positionnez légèrement la propriété translationX en lui appliquant une petite distance de déplacement depuis:

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

Notez qu'il s'agit du paramétrage de l'état "out", car l'état "in" est défini sur translateX(0).

JavaScript

À mon avis, le code JavaScript est facultatif. En effet, aucune de ces info-bulles ne devrait être requise pour accomplir une tâche dans votre interface utilisateur. Donc, si les info-bulles échouent complètement, ce n'est pas grave. Cela signifie également que nous pouvons considérer les info-bulles comme progressivement améliorées. À terme, tous les navigateurs prendront en charge :has(), et ce script peut disparaître complètement.

Le script de polyfill effectue deux actions, uniquement si le navigateur n'est pas compatible avec :has(). Commencez par vérifier la compatibilité de :has():

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

Recherchez ensuite les éléments parents des <tool-tip> et attribuez-leur un nom de classe avec lequel travailler:

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

Ensuite, injectez un ensemble de styles qui utilisent ce nom de classe, en simulant le sélecteur :has() pour le même comportement:

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

Voilà, tous les navigateurs afficheront désormais des info-bulles si :has() n'est pas compatible.

Conclusion

Maintenant que vous savez comment procéder, j'ai hâte d'utiliser l'API popup pour faciliter l'utilisation des boutons d'activation, la couche supérieure pour éviter les problèmes liés au z-index et l'API anchor pour mieux positionner les éléments dans la fenêtre. En attendant, je vais créer des info-bulles.

Diversissons nos approches et apprenons toutes les façons de créer sur le Web.

Créez une démo, cliquez sur les liens tweet me, et je l'ajouterai à la section "Remix" de la communauté ci-dessous.

Remix de la communauté

Rien à afficher pour le moment.

Ressources