Como criar um componente de dica

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

Nesta postagem, quero compartilhar minha opinião sobre como criar um elemento personalizado <tool-tip> adaptável e acessível. Teste a demonstração e veja a fonte.

Uma dica aparece com vários exemplos e esquemas de cores

Se preferir vídeo, aqui está uma versão do YouTube desta postagem:

Visão geral

Uma dica é uma sobreposição não modal, sem bloqueio e não interativa que contém informações complementares para interfaces do usuário. Ele fica oculto por padrão e não é exibido quando o cursor é passado ou focado em um elemento associado. 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. Um usuário precisa conseguir concluir totalmente a tarefa sem uma dica.

O que fazer: sempre rotule as entradas.
Não: confie em dicas em vez de rótulos

Dica de alternância x dica

Como muitos componentes, há descrições variadas do que é uma dica, por exemplo, em MDN, WAI ARIA, Sarah Higley e inclusive Components. Gosto da separação entre dicas de ferramentas e de alternância. Uma dica precisa conter informações complementares não interativas, enquanto uma dica pode conter interatividade e informações importantes. O principal motivo para essa divisão é a acessibilidade, ou seja, como os usuários devem navegar até o pop-up e ter acesso às informações e aos botões dele. As dicas ficam complexas rapidamente.

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

Esse desafio da GUI foi apenas uma dica, tentando fazer quase tudo com o CSS. Veja como criá-lo.

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>. 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 texto. 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, ele é reconhecido como uma dica. Veja no exemplo a seguir como o primeiro elemento do link tem um elemento de dica reconhecido na árvore e o segundo não tem? O segundo não tem a função. Na seção de estilos, vamos melhorar a visualização em árvore.

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 é focalizável. 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 focalizável. Se um leitor de tela não entender a função 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 precisa disso. Os leitores de tela anexam o conteúdo ao elemento pai e, assim, ele não precisa do foco para ser acessível. Aqui, podemos usar inert para garantir que nenhum usuário encontre acidentalmente esse conteúdo de dica no fluxo da guia:

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

Outra captura de tela da árvore de acessibilidade do Chrome DevTools, desta vez sem o
elemento de dica.

Optei por usar atributos como interface para especificar a posição da dica. Por padrão, todos os <tool-tip>s assumem uma posição "superior", mas essa posição pode ser personalizada em um elemento adicionando tip-position:

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

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

Costumo usar atributos em vez de classes para coisas como essa, de modo que a <tool-tip> não possa ter várias posições atribuídas a ele 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 de alt com usuários que enxergam, 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>

Captura de tela de uma imagem com uma dica que diz &quot;O logotipo da caveira dos desafios da GUI&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 o acrônimo HTML sublinhado e uma dica acima
que diz &quot;Hyper Text Markup Language&quot;.

Acessibilidade

Como escolhi criar dicas em vez de alternância, esta seção é muito mais simples. Primeiro, vou descrever como é a experiência do usuário desejada:

  1. Em espaços restritos ou interfaces desorganizadas, oculte mensagens complementares.
  2. Quando um usuário passa o cursor, foca ou usa toque para interagir com um elemento, a mensagem é revelada.
  3. Ao passar o cursor, o foco ou o toque terminar, oculte a mensagem novamente.
  4. Por fim, garanta que qualquer movimento seja reduzido se um usuário tiver especificado uma preferência por movimento reduzido.

Nossa meta é enviar mensagens complementares sob demanda. Um usuário com um mouse ou teclado pode passar o cursor para revelar a mensagem e lê-la com os olhos. Um usuário de leitor de tela sem visão pode focar para revelar a mensagem e recebê-la de forma sonora pela 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 da dica e o inrert. O restante é testá-lo e verificar se a experiência do usuário revela a mensagem de dica corretamente. Após os testes, não ficou claro qual parte da mensagem audível é uma dica. Ele também pode ser visto durante a depuração na árvore de acessibilidade. O texto do link de "top" é executado juntos, sem hesitação, com "Look, tooltips!". O leitor de tela não quebra nem identifica o texto como conteúdo de dica.

Uma
captura de tela da árvore de acessibilidade do Chrome DevTools com o texto do link
&quot;top Ei, uma dica!&quot;.

Adicione um pseudoelemento somente para leitor de tela ao <tool-tip> e podemos adicionar nosso próprio texto de solicitação para usuários não visuais.

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

Confira abaixo a árvore de acessibilidade atualizada, que agora tem um ponto e vírgula após o texto do link e uma solicitação para a dica "Tem dica: ".

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

Agora, quando um usuário do leitor de tela focar o link, ele dirá "top" e faz uma pequena pausa, anunciando "tem dica: aparência, dicas". Isso dá a um usuário de leitor de tela algumas dicas interessantes de UX. A hesitação faz uma boa separação entre o texto do link e a dica. Além disso, quando a mensagem "tem dica" for anunciada, um usuário de leitor de tela poderá cancelá-la facilmente se já tiver ouvido a mensagem. É muito semelhante a passar o cursor e tirar o cursor rapidamente, como você já viu a mensagem complementar. Isso parecia uma boa paridade de UX.

Estilos

O elemento <tool-tip> será um filho do elemento para o qual ele representa as mensagens complementares. Portanto, vamos começar com o essencial para o efeito de sobreposição. Remova do fluxo do documento com position absolute:

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

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

Compatibilidade com navegadores

  • 105
  • 105
  • 121
  • 15,4

Origem

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

Não se preocupe muito com o suporte ao navegador. Primeiro, lembre que essas dicas são complementares. Se não funcionarem, não deve haver problema. Depois, na seção JavaScript, vamos implantar um script para usar o polyfill na funcionalidade necessária para navegadores sem suporte a :has().

Em seguida, vamos tornar as dicas não interativas para que elas 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 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 o tool-tip que contém elementos pais reconheça a interatividade do usuário para alternar a visibilidade de uma dica filha. Os usuários de mouse podem passar o cursor, usar teclado ou leitor de tela para focar e tocar no dispositivo.

Com a sobreposição de mostrar e ocultar funcionando para usuários que enxergam, é hora de adicionar alguns estilos para aplicação de temas, posicionamento e adição da forma de triângulo à bolha. Os estilos a seguir começam a usar propriedades personalizadas, com base em onde estamos até agora, mas também adicionando sombras, tipografia e cores para que se pareça com uma dica flutuante:

Captura
de tela da dica 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 de tema

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

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

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

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

Da direita para a esquerda

Para oferecer suporte a modos de leitura da direita para a esquerda, uma propriedade personalizada armazenará o valor da direção do documento em um valor de -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 na localização do 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, ele 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 maneira lógica com as propriedades inset-block ou inset-inline para processar as posições físicas e lógicas. O código a seguir mostra como cada uma das quatro posições são estilizadas para direções da esquerda para a direita e da direita para a esquerda.

Alinhamento superior e de bloco-início

Uma
captura de tela mostrando a diferença de posicionamento entre a posição superior da esquerda para a direita
e a posição superior da direita para a 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 inline

Uma
captura de tela mostrando a diferença de posicionamento entre a posição direita da esquerda para a direita
e a posição inline da direita para a 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 de bloco

Uma
captura de tela mostrando a diferença de posicionamento entre a posição inferior esquerda para a direita
e a posição final de bloco da direita para a 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 da 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, primeiro, vamos animar a opacidade para todos os usuários, já que é uma transição de movimento reduzida geralmente segura. Em seguida, vamos animar a posição da transformação para que a dica pareça deslizada do elemento pai.

Uma transição padrão segura e significativa

Defina o estilo do elemento da dica para a opacidade e transformação da transição, desta forma:

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

Adicionar movimento à transição

Para cada um dos lados, uma dica pode aparecer. Se o usuário concordar com o movimento, posicione levemente a propriedade translateX fornecendo uma pequena distância para virar 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 esse é o estado "out", já que o estado "in" fica em translateX(0).

JavaScript

Na minha opinião, o JavaScript é opcional. Isso ocorre porque nenhuma dessas dicas precisa ser uma leitura obrigatória para realizar uma tarefa na interface. Portanto, se as dicas falharem completamente, não será grande coisa. Isso também significa que podemos tratar as dicas como aprimoradas progressivamente. Todos os navegadores serão compatíveis com :has(), e esse script pode ser desativado completamente.

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

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

Em seguida, encontre os elementos pais de <tool-tip>s e dê 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 usam esse nome de classe, simulando o seletor :has() para exatamente 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)
}

Agora todos os navegadores vão mostrar dicas se não houver suporte para :has().

Conclusão

Agora que você sabe como fiz isso, o que você faria?‽ 🙂 Estou ansioso para a API popup facilitar as dicas, a camada superior para nenhuma batalha de Z-index e a API anchor para melhor posicionar as coisas na janela. Até lá, vou fazer dicas.

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

Crie uma demonstração, envie um tweet para mim e os adicionarei à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não há nada aqui.

Recursos