Como criar um componente de dica

Uma visão geral básica sobre como criar um elemento personalizado de dica adaptável a cores e acessível.

Neste post, quero compartilhar minhas ideias sobre como criar um elemento personalizado <tool-tip> acessível e adaptável a cores. Teste a demonstração e confira o código-fonte.

Uma dica é exibida usando uma variedade de exemplos e esquemas de cores

Se preferir vídeos, confira a versão desta postagem no YouTube:

Visão geral

Uma dica é uma sobreposição não modal, não bloqueante e não interativa que contém informações adicionais para interfaces do usuário. Ele fica oculto por padrão e é reexibido quando um elemento associado é passado ou em foco. Não é possível selecionar ou interagir diretamente com uma dica. As dicas não substituem rótulos ou outras informações de alto valor. O usuário precisa conseguir concluir a tarefa sem uma dica.

Faça: sempre rotule as entradas.
Não faça: depender de dicas de ferramentas em vez de marcadores

Toggletip x Tooltip

Como muitos componentes, há descrições variadas do que é uma dica, por exemplo, no MDN, WAI ARIA, Sarah Higley e Componentes inclusivos. Eu gosto da separação entre dicas de ferramentas e dicas de alternância. Uma dica precisa conter informações suplementares não interativas, enquanto uma dica de alternância pode conter interatividade e informações importantes. O principal motivo da divisão é a acessibilidade, como os usuários devem navegar até o pop-up e ter acesso às informações e botões contidos nele. As dicas de alternar ficam complexas rapidamente.

Confira um vídeo de uma dica do site Designcember (link em inglês), uma sobreposição com interatividade que o usuário pode fixar para abrir e explorar, depois fechar com a claridade dispensar ou usando a tecla de escape:

Este desafio de GUI seguiu o caminho de uma dica, tentando fazer quase tudo com CSS. Confira como criá-la.

Marcação

Escolhi usar um elemento personalizado <tool-tip>. Os autores não precisam transformar elementos personalizados em componentes da Web se não quiserem. O navegador vai tratar <foo-bar> como um <div>. Pense em um elemento personalizado como um nome de classe com menos especificidade. Não há JavaScript envolvido.

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

É como um div com algum texto dentro. Podemos vincular à árvore de acessibilidade de leitores de tela compatíveis adicionando [role="tooltip"].

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

Agora, para os leitores de tela, isso é reconhecido como uma dica. Veja no exemplo a seguir como o primeiro elemento do link tem um elemento de dica reconhecido em sua árvore e o segundo não tem? O segundo não tem o papel. Na seção de estilos, vamos melhorar essa visualização em árvore.

Uma
captura de tela da árvore de acessibilidade do Chrome DevTools representando o HTML. Mostra um
link com o texto &quot;top ; Has tooltip: Hey, a tooltip!&quot; que pode ser focado. Dentro dele,
há um texto estático de &quot;top&quot; e um elemento de dica.

Em seguida, precisamos que a dica não seja selecionável. Se um leitor de tela não entender o papel da dica, ele vai permitir que os usuários concentrem o <tool-tip> para ler o conteúdo, e a experiência do usuário não vai precisar disso. Os leitores de tela anexam o conteúdo ao elemento pai e, portanto, não precisam que o foco seja disponibilizado. Aqui, podemos usar inert para garantir que nenhum usuário encontre acidentalmente esse conteúdo de dica no fluxo de guias:

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

Outra captura de tela da árvore de acessibilidade do Chrome DevTools. Desta vez, o
elemento de dica de ferramenta está ausente.

Então, escolhi usar atributos como a interface para especificar a posição do tooltip. Por padrão, todas as <tool-tip>s assumirão uma posição "superior", mas ela pode ser personalizada em um elemento adicionando tip-position:

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

Uma
captura de tela de um link com uma dica à direita dizendo &quot;Uma dica&quot;.

Eu costumo usar atributos em vez de classes para coisas como essa, para que o <tool-tip> não possa ter várias posições atribuídas ao mesmo tempo. Só pode haver um ou nenhum.

Por fim, coloque elementos <tool-tip> dentro do elemento para o qual você quer fornecer uma dica. Aqui, compartilho o texto alt com usuários videntes colocando uma imagem e um <tool-tip> dentro de um elemento <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>

Uma
captura de tela de uma imagem com uma dica que diz &quot;O logotipo do crânio
GUI Challenges&quot;.

Aqui, coloco um <tool-tip> dentro de um elemento <abbr>:

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

Uma captura de tela de um parágrafo com a sigla HTML sublinhada e uma dica acima dela dizendo &quot;linguagem de marcação de hipertexto&quot;.

Acessibilidade

Como escolhi criar dicas de ferramentas e não dicas de alternância, esta seção é muito mais simples. Primeiro, vamos descrever a experiência do usuário que queremos:

  1. Em espaços restritos ou interfaces desorganizadas, oculte mensagens complementares.
  2. Quando um usuário passa o cursor, foca ou usa o toque para interagir com um elemento, revele a mensagem.
  3. Quando o cursor, o foco ou o toque terminar, oculte a mensagem novamente.
  4. Por fim, verifique se todos os movimentos são reduzidos caso o usuário especifique uma preferência por eles reduzidos.

Nosso objetivo é enviar mensagens complementares sob demanda. Um usuário com visão que usa um mouse ou teclado pode passar o cursor sobre a mensagem para revelá-la e ler com os olhos. Um usuário de leitor de tela sem visão pode focar para revelar a mensagem, recebendo-a de forma audível por meio de sua ferramenta.

Captura de tela do VoiceOver do MacOS lendo um link com uma dica

Na seção anterior, abordamos a árvore de acessibilidade, o papel e a inércia da dica. O que resta é testá-la e verificar se a experiência do usuário revela a mensagem da dica corretamente. Ao testar, não fica claro qual parte da mensagem sonora é uma dica. Isso também pode ser visto durante a depuração na árvore de acessibilidade. O texto do link "top" é executado junto, sem hesitação, com "Look, tooltips!". O leitor de tela não divide nem identifica o texto como conteúdo da dica.

Uma
captura de tela da árvore de acessibilidade do Chrome DevTools em que o texto do link diz
&quot;Olá, uma dica!&quot;.

Adicione um pseudoelemento de leitor de tela ao <tool-tip> e podemos adicionar nosso próprio texto de solicitação para usuários com deficiência visual.

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

Abaixo, você pode conferir a árvore de acessibilidade atualizada, que agora tem um ponto e vírgula após o texto do link e um prompt para a dica "Tem dica: ".

Uma captura de tela atualizada da árvore de acessibilidade do Chrome DevTools em que o texto do link tem uma frase melhorada, &quot;top ; Has tooltip: Hey, a tooltip!&quot;.

Agora, quando um usuário de leitor de tela concentra o link, ele diz "top" e faz uma pequena pausa, em seguida, anuncia "has tooltip: look, tooltips". Isso dá ao usuário do leitor de tela algumas dicas interessantes de UX. A hesitação cria uma boa separação entre o texto do link e a dica. Além disso, quando a mensagem "tem dica" é anunciada, um usuário de leitor de tela pode cancelá-la facilmente, caso já tenha ouvido isso antes. É muito semelhante a passar o cursor e remover o cursor rapidamente, como você já viu a mensagem complementar. Isso parecia uma boa paridade de UX.

Estilos

O elemento <tool-tip> será filho do elemento que representa mensagens complementares. Então, vamos começar com os elementos essenciais para o efeito de sobreposição. Remova o documento do fluxo com position absolute:

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

Se o pai não for um contexto de empilhamento, a dica de ferramenta vai se posicionar no mais próximo, o que não é o que queremos. Há um novo seletor no bloco que pode ajudar, :has():

Compatibilidade com navegadores

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

Origem

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

Não se preocupe muito com o suporte do navegador. Primeiro, lembre-se de que essas dicas são complementares. Se eles não funcionarem, não tem problema. Em segundo lugar, na seção de JavaScript, vamos implantar um script para polyfillar a funcionalidade necessária para navegadores sem suporte a :has().

Em seguida, vamos tornar as dicas não interativas para que não roubem eventos de ponteiro do elemento pai:

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

Em seguida, oculte a dica com opacidade para que possamos fazer a transição dela com um crossfade:

tool-tip {
  opacity: 0;
}

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

:is() e :has() fazem o trabalho pesado aqui, fazendo com que tool-tip, que contém elementos pai, esteja ciente da interatividade do usuário para alternar a visibilidade de um ícone de dica filho. Os usuários de mouse podem passar o cursor, os usuários de teclado e leitor de tela podem focar, e os usuários de toque podem tocar.

Com a sobreposição de mostrar e ocultar funcionando para usuários com visão, é hora de adicionar alguns estilos para definir o tema, posicionar e adicionar a forma triangular à bolha. Os estilos a seguir começam a usar propriedades personalizadas, com base no que já temos, mas também adicionando sombras, tipografia e cores para que pareça um tooltip flutuante:

Uma
captura de tela da dica de ferramenta no modo escuro, flutuando sobre o link &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;
}

Ajustes do tema

A dica tem apenas algumas cores para gerenciar, já que a cor do texto é herdada da página pela palavra-chave CanvasText do sistema. Além disso, como criamos propriedades personalizadas para armazenar os valores, podemos atualizar apenas essas propriedades e deixar o tema cuidar do restante:

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

Uma
captura de tela lado a lado das versões clara e escura da dica.

Para o tema claro, adaptamos o plano de fundo para branco e deixamos as sombras muito menos fortes ajustando a opacidade.

Da direita para a esquerda

Para oferecer suporte aos modos de leitura da direita para a esquerda, uma propriedade personalizada vai armazenar o valor da direção do documento em -1 ou 1, respectivamente.

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

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

Isso pode ser usado para ajudar no posicionamento da dica:

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

Além de ajudar a encontrar o triângulo:

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

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

Por fim, também pode ser usado para transformações lógicas em translateX():

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

Posicionamento da dica

Posicione a dica de ferramenta de forma lógica com as propriedades inset-block ou inset-inline para processar as posições física e lógica da dica de ferramenta. O código abaixo mostra como cada uma das quatro posições recebe estilo para direções da esquerda para a direita e da direita para a esquerda.

Alinhamento superior e de início do bloco

Uma
captura de tela mostrando a diferença de posicionamento entre a posição superior esquerda-direita
e a posição superior direita-esquerda.

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

Alinhamento à direita e final inline

Uma
captura de tela mostrando a diferença de posicionamento entre a posição direita-esquerda
e a posição final inline direita-esquerda.

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

Alinhamento inferior e final do bloco

Uma
captura de tela mostrando a diferença de posicionamento entre a posição inferior esquerda-direita
e a posição do final do bloco direita-esquerda.

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

Alinhamento à esquerda e início inline

Uma
captura de tela mostrando a diferença de posicionamento entre a posição esquerda para a direita
e a posição de início inline da direita para a esquerda.

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

Animação

Até agora, alternamos apenas a visibilidade da dica. Nesta seção, vamos animar a opacidade para todos os usuários, já que é uma transição de movimento reduzido geralmente segura. Em seguida, vamos animar a posição de transformação para que a dica apareça deslizando para fora do elemento pai.

Uma transição padrão segura e significativa

Defina o estilo do elemento da dica para a transição de opacidade e transformação da seguinte maneira:

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

Como adicionar movimento à transição

Para cada um dos lados em que uma dica pode aparecer, se o usuário aceitar o movimento, posicione levemente a propriedade translateX dando a ela uma pequena distância para viajar de:

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

Observe que isso está definindo o estado "out", já que o estado "in" está em translateX(0).

JavaScript

Na minha opinião, o JavaScript é opcional. Isso ocorre porque nenhuma dessas tooltips precisa ser lida para realizar uma tarefa na interface. Portanto, se as tooltips falharem completamente, não será um problema. Isso também significa que podemos tratar as dicas de ferramentas como aprimoradas progressivamente. Eventualmente, todos os navegadores vão oferecer suporte a :has(), e esse script poderá ser totalmente removido.

O script de polyfill faz duas coisas e só faz isso se o navegador não oferecer suporte a :has(). Primeiro, verifique se há suporte para :has():

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

Em seguida, encontre os elementos pai de <tool-tip>s e atribua a eles um nome de classe para trabalhar:

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

Em seguida, injete um conjunto de estilos que usem essa classe, simulando o seletor :has() para o mesmo comportamento:

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

Pronto. Agora todos os navegadores vão mostrar as dicas se :has() não tiver suporte.

Conclusão

Agora que você sabe como fiz isso, como você faria? 🙂 Estou ansioso para usar a API popup para facilitar os toggletips, a camada superior para não ter batalhas de z-index e a API anchor para posicionar melhor as coisas na janela. Até lá, vou criar as dicas.

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web.

Crie uma demonstração, envie links para mim e vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não há nada aqui.

Recursos