Como criar um componente de botão de divisão

Uma visão geral básica de como criar um componente de botão de divisão acessível.

Nesta postagem, quero compartilhar ideias sobre como criar um botão de divisão . Teste a demonstração.

Demonstração

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

Visão geral

Botões de divisão são botões que ocultam um botão principal e uma lista de outros. Elas são úteis para expor uma ação comum ao aninhar ações secundárias, usadas com menos frequência, até que seja necessário. Um botão de divisão pode ser crucial para ajudar um design a parecer mínimo. Um botão de divisão avançada pode até mesmo lembrar a última ação do usuário e promovê-la para a posição principal.

Um botão de divisão comum pode ser encontrado no aplicativo de e-mail. A ação principal é enviar, mas talvez você possa enviar mais tarde ou salvar um rascunho:

Um exemplo de botão de divisão, como visto em um aplicativo de e-mail.

A área de ação compartilhada é boa, pois o usuário não precisa olhar ao redor. Ele sabe que há ações essenciais de e-mail no botão "Dividir".

Peças

Vamos analisar as partes essenciais de um botão de divisão antes de discutir a orquestração geral e a experiência final do usuário. A ferramenta de inspeção de acessibilidade do VisBug é usada para mostrar uma visualização macro do componente, mostrando aspectos do HTML, estilo e acessibilidade para cada parte principal.

Os elementos HTML que compõem o botão de divisão.

Contêiner do botão de divisão de nível superior

O componente de nível mais alto é um flexbox inline, com uma classe de gui-split-button, contendo a ação principal e a .gui-popup-button.

A classe gui-split-button foi inspecionada e mostra as propriedades CSS usadas nessa classe.

Botão de ação principal

O <button> inicialmente visível e focalizável se encaixa no contêiner com duas formas de canto correspondentes para que as interações de foco, hover e ativo apareçam dentro de .gui-split-button.

O inspetor mostrando as regras CSS para o elemento de botão.

Botão pop-up ativar ou desativar

O elemento de suporte do "botão pop-up" serve para ativar e fazer alusão à lista de botões secundários. Observe que não é um <button> e não é focalizável. No entanto, é a âncora de posicionamento para .gui-popup e o host de :focus-within usado para apresentar o pop-up.

O inspetor mostrando as regras CSS para a classe gui-popup-button.

O card pop-up

É um cartão filho da âncora .gui-popup-button, posicionado de forma absoluta e encapsulando a lista de botões de forma semântica.

O inspetor mostrando as regras de CSS para a classe gui-popup

Ações secundárias

Uma <button> focalizável com um tamanho de fonte um pouco menor do que o botão de ação principal apresenta um ícone e um estilo complementar ao botão principal.

O inspetor mostrando as regras CSS para o elemento de botão.

Propriedades personalizadas

As variáveis abaixo ajudam a criar a harmonia de cores e um lugar central para modificar valores usados em todo o componente.

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --light (prefers-color-scheme: light);

.gui-split-button {
  --theme:             hsl(220 75% 50%);
  --theme-hover:  hsl(220 75% 45%);
  --theme-active:  hsl(220 75% 40%);
  --theme-text:      hsl(220 75% 25%);
  --theme-border: hsl(220 50% 75%);
  --ontheme:         hsl(220 90% 98%);
  --popupbg:         hsl(220 0% 100%);

  --border: 1px solid var(--theme-border);
  --radius: 6px;
  --in-speed: 50ms;
  --out-speed: 300ms;

  @media (--dark) {
    --theme:             hsl(220 50% 60%);
    --theme-hover:  hsl(220 50% 65%);
    --theme-active:  hsl(220 75% 70%);
    --theme-text:      hsl(220 10% 85%);
    --theme-border: hsl(220 20% 70%);
    --ontheme:         hsl(220 90% 5%);
    --popupbg:         hsl(220 10% 30%);
  }
}

Layouts e cores

Marcação

O elemento começa como uma <div> com um nome de classe personalizado.

<div class="gui-split-button"></div>

Adicione o botão principal e os elementos .gui-popup-button.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions"></span>
</div>

Observe os atributos Aria aria-haspopup e aria-expanded. Essas dicas são essenciais para que os leitores de tela conheçam a capacidade e o estado da experiência do botão de divisão. O atributo title é útil para todos.

Adicione um ícone <svg> e o elemento de contêiner .gui-popup.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup"></ul>
  </span>
</div>

Para uma posição pop-up simples, .gui-popup é filho do botão que o expande. O único problema com essa estratégia é que o contêiner .gui-split-button não pode usar overflow: hidden, porque isso vai cortar a presença do pop-up visualmente.

Uma <ul> preenchida com conteúdo de <li><button> se anuncia como uma "lista de botões" para os leitores de tela, que é exatamente a interface apresentada.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup">
      <li>
        <button>Schedule for later</button>
      </li>
      <li>
        <button>Delete</button>
      </li>
      <li>
        <button>Save draft</button>
      </li>
    </ul>
  </span>
</div>

Para dar um toque de estilo e se divertir com as cores, adicionei ícones aos botões secundários de https://heroicons.com. Os ícones são opcionais para os botões principal e secundário.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup">
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
        </svg>
        Schedule for later
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
        </svg>
        Delete
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
        </svg>
        Save draft
      </button></li>
    </ul>
  </span>
</div>

Estilos

Com HTML e conteúdo definidos, os estilos estão prontos para fornecer cor e layout.

Como definir o estilo do contêiner do botão de divisão

Um tipo de exibição inline-flex funciona bem para esse componente de ajuste, já que precisa se encaixar in-line com outros botões de divisão, ações ou elementos.

.gui-split-button {
  display: inline-flex;
  border-radius: var(--radius);
  background: var(--theme);
  color: var(--ontheme);
  fill: var(--ontheme);

  touch-action: manipulation;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

O botão de divisão.

Estilo <button>

Os botões são muito bons em disfarçar a quantidade de código necessária. Talvez seja necessário desfazer ou substituir estilos padrão do navegador, mas você também precisará aplicar alguma herança, adicionar estados de interação e se adaptar a várias preferências de usuário e tipos de entrada. Os estilos de botão se acumulam rapidamente.

Esses botões são diferentes dos botões normais porque compartilham um plano de fundo com um elemento pai. Normalmente, um botão tem a cor do plano de fundo e do texto. No entanto, eles o compartilham e aplicam apenas seus próprios conhecimentos sobre a interação.

.gui-split-button button {
  cursor: pointer;
  appearance: none;
  background: none;
  border: none;

  display: inline-flex;
  align-items: center;
  gap: 1ch;
  white-space: nowrap;

  font-family: inherit;
  font-size: inherit;
  font-weight: 500;

  padding-block: 1.25ch;
  padding-inline: 2.5ch;

  color: var(--ontheme);
  outline-color: var(--theme);
  outline-offset: -5px;
}

Adicione estados de interação com algumas pseudoclasses CSS e uso de propriedades personalizadas correspondentes para o estado:

.gui-split-button button {
  …

  &:is(:hover, :focus-visible) {
    background: var(--theme-hover);
    color: var(--ontheme);

    & > svg {
      stroke: currentColor;
      fill: none;
    }
  }

  &:active {
    background: var(--theme-active);
  }
}

O botão principal precisa de alguns estilos especiais para completar o efeito de design:

.gui-split-button > button {
  border-end-start-radius: var(--radius);
  border-start-start-radius: var(--radius);

  & > svg {
    fill: none;
    stroke: var(--ontheme);
  }
}

Por fim, para dar um toque especial, o botão e o ícone do tema claro recebem uma sombra:

.gui-split-button {
  @media (--light) {
    & > button,
    & button:is(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--theme-active);
    }
    & > .gui-popup-button > svg,
    & button:is(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--theme-active));
    }
  }
}

Um ótimo botão prestou atenção às microinterações e aos pequenos detalhes.

Uma observação sobre :focus-visible

Os estilos de botão usam :focus-visible em vez de :focus. :focus é um toque crucial para tornar uma interface do usuário acessível, mas tem uma desvantagem: a função não é inteligente se o usuário precisa ou não ver a interface, mas se aplica a qualquer foco.

O vídeo abaixo tenta detalhar essa microinteração para mostrar como :focus-visible é uma alternativa inteligente.

Definir o estilo do botão pop-up

Um flexbox 4ch para centralizar um ícone e fixar uma lista de botões pop-up. Assim como o botão principal, ele é transparente até passar o cursor ou interagir com ele, e é esticado até ser preenchido.

A seta do botão de divisão usada para acionar o pop-up.

.gui-popup-button {
  inline-size: 4ch;
  cursor: pointer;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-inline-start: var(--border);
  border-start-end-radius: var(--radius);
  border-end-end-radius: var(--radius);
}

Coloque os elementos de estado sobre passar o cursor, o foco e os ativos com o Aninhamento de CSS e o seletor funcional :is() (links em inglês):

.gui-popup-button {
  …

  &:is(:hover,:focus-within) {
    background: var(--theme-hover);
  }

  /* fixes iOS trying to be helpful */
  &:focus {
    outline: none;
  }

  &:active {
    background: var(--theme-active);
  }
}

Esses estilos são o principal gancho para mostrar e ocultar o pop-up. Quando a .gui-popup-button tiver focus em qualquer um dos filhos, defina opacity, posição e pointer-events, no ícone e no pop-up.

.gui-popup-button {
  …

  &:focus-within {
    & > svg {
      transition-duration: var(--in-speed);
      transform: rotateZ(.5turn);
    }
    & > .gui-popup {
      transition-duration: var(--in-speed);
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
  }
}

Com os estilos de entrada e saída concluídos, a última parte é fazer a transição condicional, dependendo da preferência de movimento do usuário:

.gui-popup-button {
  …

  @media (--motionOK) {
    & > svg {
      transition: transform var(--out-speed) ease;
    }
    & > .gui-popup {
      transform: translateY(5px);

      transition:
        opacity var(--out-speed) ease,
        transform var(--out-speed) ease;
    }
  }
}

Uma análise profunda do código percebe que opacidade ainda é transferida para usuários que preferem movimento reduzido.

Definir o estilo do pop-up

O elemento .gui-popup é uma lista de botões de card flutuante que usam propriedades personalizadas e unidades relativas para serem sutilmente menores, combinadas de maneira interativa com o botão principal e na marca com o uso de cores. Observe que os ícones têm menos contraste, são mais finos e a sombra tem um toque de marca azul. Assim como acontece com os botões, uma interface e uma UX fortes são resultado desses pequenos detalhes que se acumulam.

Um elemento de cartão flutuante.

.gui-popup {
  --shadow: 220 70% 15%;
  --shadow-strength: 1%;

  opacity: 0;
  pointer-events: none;

  position: absolute;
  bottom: 80%;
  left: -1.5ch;

  list-style-type: none;
  background: var(--popupbg);
  color: var(--theme-text);
  padding-inline: 0;
  padding-block: .5ch;
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  font-size: .9em;
  transition: opacity var(--out-speed) ease;

  box-shadow:
    0 -2px 5px 0 hsl(var(--shadow) / calc(var(--shadow-strength) + 5%)),
    0 1px 1px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 10%)),
    0 2px 2px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 12%)),
    0 5px 5px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 13%)),
    0 9px 9px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 14%)),
    0 16px 16px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 20%))
  ;
}

Os ícones e botões recebem cores da marca para estilizarem bem em cada cartão com tema claro e escuro:

Links e ícones para finalizar compras, usar o Quick Pay e salvar mais tarde.

.gui-popup {
  …

  & svg {
    fill: var(--popupbg);
    stroke: var(--theme);

    @media (prefers-color-scheme: dark) {
      stroke: var(--theme-border);
    }
  }

  & button {
    color: var(--theme-text);
    width: 100%;
  }
}

O pop-up do tema escuro tem adições de sombra de texto e ícone, além de uma sombra de caixa um pouco mais intensa:

O pop-up no tema escuro.

.gui-popup {
  …

  @media (--dark) {
    --shadow-strength: 5%;
    --shadow: 220 3% 2%;

    & button:not(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--ontheme);
    }

    & button:not(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--ontheme));
    }
  }
}

Estilos genéricos de ícones do <svg>

Todos os ícones são relativamente dimensionados para o botão font-size em que são usados usando a unidade ch como o inline-size. Cada um também recebe alguns estilos para ajudar a delinear ícones suaves e suaves.

.gui-split-button svg {
  inline-size: 2ch;
  box-sizing: content-box;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 2px;
}

Layout da direita para a esquerda

As propriedades lógicas fazem todo o trabalho complexo. Veja a lista de propriedades lógicas usadas: - display: inline-flex cria um elemento Flex inline. - padding-block e padding-inline como um par, em vez da abreviação padding, têm os benefícios do padding dos lados lógicos. - border-end-start-radius e amigos arredondarão os cantos com base na direção do documento. - inline-size em vez de width garante que o tamanho não esteja vinculado a dimensões físicas. - border-inline-start adiciona uma borda ao início, que pode estar à direita ou à esquerda, dependendo da direção do script.

JavaScript

Quase todo o JavaScript a seguir é para melhorar a acessibilidade. Duas das minhas bibliotecas auxiliares são usadas para facilitar um pouco as tarefas. O BlingBlingJS é usado para consultas sucintas do DOM e fácil configuração do listener de eventos, enquanto o roving-ux ajuda a facilitar interações acessíveis de teclado e gamepad para o pop-up.

import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'

const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')

Com as bibliotecas acima importadas e os elementos selecionados e salvos em variáveis, a atualização da experiência está a algumas funções de ser concluída.

Índice itinerante

Quando um teclado ou leitor de tela foca a .gui-popup-button, queremos encaminhar o foco para o primeiro botão (ou mais recentemente) da .gui-popup. A biblioteca nos ajuda a fazer isso com os parâmetros element e target.

popupButtons.forEach(element =>
  rovingIndex({
    element,
    target: 'button',
  }))

O elemento agora transmite o foco para os filhos <button> de destino e ativa a navegação padrão das teclas de seta para procurar opções.

Ativando aria-expanded

Embora seja visualmente aparente que um pop-up está sendo mostrado e escondido, um leitor de tela precisa de mais do que indicações visuais. O JavaScript é usado aqui para complementar a interação :focus-within orientada por CSS, alternando um atributo adequado do leitor de tela.

popupButtons.on('focusin', e => {
  e.currentTarget.setAttribute('aria-expanded', true)
})

popupButtons.on('focusout', e => {
  e.currentTarget.setAttribute('aria-expanded', false)
})

Ativando a chave Escape

O foco do usuário foi intencionalmente enviado para uma armadilha, o que significa que precisamos oferecer uma maneira de sair. A maneira mais comum é permitir o uso da chave Escape. Para fazer isso, preste atenção aos pressionamentos de tecla no botão pop-up, já que todos os eventos de teclado nos filhos serão exibidos nesse pai.

popupButtons.on('keyup', e => {
  if (e.code === 'Escape')
    e.target.blur()
})

Se o botão pop-up detectar um pressionamento de tecla Escape, ele vai remover o foco de si mesmo com blur().

Cliques no botão de divisão

Por fim, se o usuário clicar, tocar ou usar o teclado interagir com os botões, o aplicativo vai precisar executar a ação adequada. A propagação de eventos é usada novamente aqui, mas, desta vez, no contêiner .gui-split-button, para capturar cliques no botão de um pop-up filho ou da ação principal.

splitButtons.on('click', event => {
  if (event.target.nodeName !== 'BUTTON') return
  console.info(event.target.innerText)
})

Conclusão

Agora que você sabe como eu fiz isso, o que você faria ‽ 🙂

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