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 uma maneira de criar um botão de divisão . Teste a demonstração.

Demonstração

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

Visão geral

Botões de divisão são botões que escondem um botão principal e uma lista de outros botões. Eles são úteis para expor uma ação comum ao aninhar a secundária, usada com menos frequência ações até que seja necessário. Um botão de divisão pode ser crucial para ajudar um design cheio parecer mínima. Um botão de divisão avançado pode até lembrar a última ação do usuário e promovê-lo para a posição principal.

Um botão de divisão comum pode ser encontrado no aplicativo de e-mail. A ação principal é enviado, mas talvez você possa enviar depois 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, porque o usuário não precisa olhar ao redor. Eles saiba que o botão de divisão contém ações essenciais de e-mail.

Peças

Vamos analisar as partes essenciais de um botão de divisão antes de discutir suas orquestração geral e experiência final do usuário. Acessibilidade do VisBug (em inglês) de inspeção é usada aqui para ajudar a mostrar uma visã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 o .gui-popup-button.

A classe gui-split-button inspecionou e mostrou as propriedades CSS usadas nessa classe.

O 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 focus, passe o cursor e interações ativas para aparecem em .gui-split-button.

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

O botão de ativação/desativação do pop-up

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

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

O cartão pop-up

Este é um cartão flutuante filho à âncora dele .gui-popup-button, posição absoluta e encapsulando semanticamente a lista de botões.

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

As ações secundárias

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

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

Propriedades personalizadas

As variáveis a seguir ajudam a criar 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 é fundamental que os leitores de tela estejam cientes da capacidade e do estado de divisão experiência do botã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 um posicionamento de pop-up simples, .gui-popup é um filho do botão que o expande. O único problema dessa estratégia é o .gui-split-button o contêiner não pode usar overflow: hidden, já que isso impedirá que o pop-up seja visualmente presentes.

Uma <ul> preenchida com conteúdos de <li><button> vai anunciar a si mesma como um "botão lista" para leitores de tela, que é precisamente a interface que está sendo 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 estilo e se divertir com as cores, adicionei ícones aos botões secundários em https://heroicons.com. Os ícones são opcionais para ambos os botões primário 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 o HTML e o conteúdo no lugar, 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 wrapper, porque devem caber inline 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.

O estilo <button>

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

Esses botões são diferentes dos botões normais porque compartilham um plano de fundo a um elemento pai. Normalmente, um botão tem a cor do plano de fundo e do texto. No entanto, eles compartilham e aplicam apenas o próprio histórico na 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;
}

Adicionar estados de interação com alguns CSS pseudoclasses e o uso de correspondência propriedades personalizadas 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 um pouco mais de estilo, o botão e o ícone do tema claro recebem uma shadow:

.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 um desvantagem: não é inteligente saber se o usuário precisa ou não vê-lo ou não, ela se aplicará a qualquer foco.

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

Estilo do botão pop-up

Um flexbox 4ch para centralizar um ícone e ancorar uma lista de botões pop-up. Gostei botão principal, é transparente até você passar o cursor ou interagir e estendido para preencher.

A parte da 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);
}

Camada nos estados ativos, de foco e de passagem do cursor com CSS Transição e os Seletor funcional do :is():

.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 o .gui-popup-button tem focus em qualquer um dos filhos, definido 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 é usar condicionalmente transformações de transição 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;
    }
  }
}

Um olhar aprofundado no código vai perceber que a opacidade ainda está em transição para os usuários. que preferem movimento reduzido.

Estilizar o pop-up

O elemento .gui-popup é uma lista de botões de cartão flutuante que usa propriedades personalizadas e as unidades relativas sejam sutilmente menores, combinadas de maneira interativa com o e na marca com o uso de cores. Observe que os ícones têm menos contraste, são mais finas e a sombra tem um toque de marca azul. Assim como nos botões, Uma interface e uma UX fortes são o resultado desses pequenos detalhes se acumularem.

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 de marca para que tenham um estilo adequado em cada tema escuro e cartão com tema claro:

Links e ícones para finalizar a compra, usar o Quick Pay e salvar para 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 sombras de ícone e texto, além de um pouco mais sombra intensa da caixa:

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 de ícone genéricos do <svg>

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

.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. Confira a lista de propriedades lógicas usadas: - display: inline-flex cria um elemento flexível inline. - padding-block e padding-inline como um par, em vez de padding aproveite os benefícios do preenchimento dos lados lógicos. - border-end-start-radius e friends vai cantos arredondados com base na direção do documento. - inline-size em vez de width garante que o tamanho não fique 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 todos os JavaScripts a seguir servem para melhorar a acessibilidade. Dois dos meus bibliotecas auxiliares são usadas para tornar as tarefas um pouco mais fáceis. BlingBlingJS é usado para palavras consultas DOM e fácil configuração de listener de eventos, enquanto roving-ux ajuda a facilitar o acesso interações entre 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, mas faltam algumas funções para o upgrade da experiência.

Índice móvel

Quando um teclado ou leitor de tela focar a .gui-popup-button, queremos avançar o foco para o primeiro (ou o último botão em foco) da .gui-popup. A biblioteca nos ajuda a fazer isso com element e target. parâmetros.

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

O elemento agora passa o foco para os filhos da <button> de destino e ativa a navegação com as teclas de seta padrão para conferir as opções.

Alternando aria-expanded

Embora seja visualmente aparente que um pop-up está sendo exibido e ocultado, um leitor de tela precisa de mais do que dicas 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 fornecer uma maneira de sair. A maneira mais comum é permitir o uso da chave Escape. Para isso, verifique se o botão pop-up é pressionado, pois todos os eventos de teclado filhos aparecerão até este pai.

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

Se o botão pop-up vê qualquer pressionamento de tecla Escape, ele remove o foco de si mesmo com blur().

Cliques no botão de divisão

Por fim, se o usuário clicar, tocar ou interagir com os botões, o o aplicativo precisa realizar a ação adequada. O balão de eventos é usado novamente aqui, mas desta vez no contêiner .gui-split-button, para capturar o botão cliques 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 adicione os links acesse a seção "Remixes da comunidade" abaixo.

Remixes da comunidade