Uma visão geral básica de como criar um componente de botão dividido acessível.
Neste post, quero compartilhar uma ideia de como criar um botão dividido . Teste a demonstração.
Se preferir vídeos, confira a versão desta postagem no YouTube:
Visão geral
Os botões divididos ocultam um botão principal e uma lista de botões adicionais. 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 dividido pode ser crucial para ajudar um design ocupado a parecer mínimo. Um botão de divisão avançado pode até lembrar a última ação do usuário e promover essa ação para a posição principal.
Um botão dividido comum pode ser encontrado no seu aplicativo de e-mail. A ação principal é enviar, mas talvez você possa enviar mais tarde ou salvar um rascunho:
A área de ação compartilhada é boa, porque o usuário não precisa procurar. Eles sabem que as ações de e-mail essenciais estão contidas no botão de divisão.
Peças
Vamos detalhar as partes essenciais de um botão dividido 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 aqui para mostrar uma visão macro do componente, destacando aspectos do HTML, estilo e acessibilidade para cada parte principal.
Contêiner do botão dividido 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
.
O botão de ação principal
O <button>
inicialmente visível e com foco se encaixa no contêiner com
duas formas de canto correspondentes para
foco,
passar o cursor e
interações ativas para
aparecerem contidas em .gui-split-button
.
Botão de ativação do pop-up
O elemento de suporte "botão de pop-up" serve para ativar e fazer referência à lista de
botões secundários. Ele não é um <button>
e não pode ser focado. No entanto,
ela é a âncora de posicionamento para .gui-popup
e o host para :focus-within
usado
para apresentar o pop-up.
O card pop-up
Esse é um card flutuante filho da âncora
.gui-popup-button
, posicionado de forma absoluta e
encerrando semanticamente a lista de botões.
As ações secundárias
Um <button>
com foco e um tamanho de fonte ligeiramente menor do que o botão
de ação principal tem um ícone e um estilo
complementar ao botão principal.
Propriedades personalizadas
As variáveis a seguir ajudam a criar harmonia de cores e um local central para modificar os 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 um <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
importantes para que os leitores de tela estejam cientes do recurso e do estado da
experiência do botão dividido. 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 posicionamento simples de pop-up, .gui-popup
é um 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 ele corta o pop-up
visualmente.
Uma <ul>
preenchida com conteúdo <li><button>
será anunciada como uma "lista
de botões" para 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 especial 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 principais e secundários.
<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 em vigor, os estilos estão prontos para fornecer cor e layout.
Como estilizar o contêiner do botão dividido
Um tipo de exibição inline-flex
funciona bem para esse componente de agrupamento, porque
ele precisa se encaixar em outros botões, ações ou elementos divididos.
.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 estilo <button>
Os botões são muito bons para disfarçar a quantidade de código necessária. Talvez seja necessário desfazer ou substituir os estilos padrão do navegador, mas você também vai precisar aplicar alguma 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 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 o plano de fundo e a cor do texto. No entanto, eles compartilham e aplicam apenas o próprio plano de fundo 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;
}
Adicione estados de interação com algumas pseudoclasses do CSS e use 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 do 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 presta atenção às microinterações e aos pequenos detalhes.
Uma observação sobre :focus-visible
Observe como os estilos de botão usam :focus-visible
em vez de :focus
. :focus
é um toque crucial para criar uma interface do usuário acessível, mas tem uma
desvantagem: ela não é inteligente em relação a se o usuário precisa ou não
vê-la. Ela será aplicada para qualquer foco.
O vídeo abaixo tenta detalhar essa microinteração para mostrar como
:focus-visible
é uma alternativa inteligente.
Dar estilo ao botão do pop-up
Um flexbox 4ch
para centralizar um ícone e ancorar uma lista de botões pop-up. Assim
como o botão principal, ele é transparente até que o cursor passe por ele ou interaja
com ele, e é esticado para preencher.
.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 em estados de passar o cursor, foco e ativo com aninhamento
de CSS e o
seletor funcional :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 gancho principal para mostrar e ocultar o pop-up. Quando o
.gui-popup-button
tiver focus
em qualquer uma das crianças, 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 etapa é transformar transformações condicionalmente, 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 atento ao código notaria que a opacidade ainda está em transição para usuários que preferem movimentos reduzidos.
Dar estilo ao pop-up
O elemento .gui-popup
é uma lista de botões de cartão flutuante que usa propriedades personalizadas
e unidades relativas para ser sutilmente menor, correspondendo interativamente ao botão
principal e à marca com o uso de cores. Os ícones têm menos contraste,
são mais finos e a sombra tem um toque de azul da marca. Assim como nos botões,
uma interface e uma experiência do usuário fortes são o resultado desses pequenos detalhes.
.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 estilizar bem em cada card com tema escuro e claro:
.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ção de sombra de texto e ícone, além de uma sombra de caixa um pouco mais intensa:
.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 <svg>
Todos os ícones são dimensionados de forma relativa ao botão font-size
em que são usados,
usando a unidade ch
como
inline-size
. Cada um deles também tem alguns estilos para ajudar a definir í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.
Confira 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 de abreviação padding
, aproveite os benefícios do preenchimento dos lados lógicos.
- border-end-start-radius
e
amigos vão
ter cantos arredondados 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 as tarefas. O BlingBlingJS é usado para consultas DOM sucintas e configuração fácil de listener de eventos, enquanto roving-ux facilita as 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 o .gui-popup-button
, queremos
encaminhar o foco para o primeiro botão (ou o mais recentemente focado) no
.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 permite
a navegação padrão com as teclas de seta para navegar pelas opções.
Como ativar/desativar aria-expanded
Embora seja visualmente aparente que um pop-up está sendo mostrado 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 apropriado para leitores de tela.
popupButtons.on('focusin', e => {
e.currentTarget.setAttribute('aria-expanded', true)
})
popupButtons.on('focusout', e => {
e.currentTarget.setAttribute('aria-expanded', false)
})
Como ativar a chave Escape
O foco do usuário foi enviado intencionalmente 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, observe as teclas pressionadas no botão do pop-up, já que todos os eventos de teclado em
elementos filhos vão ser transmitidos para o elemento pai.
popupButtons.on('keyup', e => {
if (e.code === 'Escape')
e.target.blur()
})
Se o botão de pop-up detectar qualquer tecla Escape
pressionada, 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 usar o teclado para interagir com os botões, o
aplicativo precisará realizar a ação adequada. O evento de bubbling é usado
novamente aqui, mas desta vez no contêiner .gui-split-button
, para detectar cliques
de 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, como você faria? 🙂
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.