Una descripción general fundamental de cómo compilar un componente de botón dividido accesible.
En esta publicación, quiero compartir mi forma de pensar para crear un botón dividido. Prueba la demostración.
Si prefieres ver un video, aquí tienes una versión de esta publicación en YouTube:
Descripción general
Los botones divididos son botones que ocultan un botón principal y una lista de botones adicionales. Son útiles para exponer una acción común mientras anidan acciones secundarias que se usan con menos frecuencia hasta que se necesitan. Un botón dividido puede ser fundamental para ayudar a que un diseño cargado se sienta minimalista. Un botón de división avanzado incluso puede recordar la última acción del usuario y promocionarla a la posición principal.
Puedes encontrar un botón de división común en tu aplicación de correo electrónico. La acción principal es enviar, pero tal vez puedas enviarlo más tarde o guardar un borrador:
El área de acción compartida es agradable, ya que el usuario no necesita mirar a su alrededor. Sabe que las acciones esenciales de correo electrónico se encuentran en el botón de división.
Piezas
Analicemos las partes esenciales de un botón dividido antes de analizar su orquestación general y la experiencia del usuario final. Aquí se usa la herramienta de inspección de accesibilidad de VisBug para mostrar una vista macro del componente y mostrar aspectos del HTML, el estilo y la accesibilidad de cada parte principal.
Contenedor de botones divididos de nivel superior
El componente de nivel superior es un flexbox intercalado, con una clase de gui-split-button
, que contiene la acción principal y .gui-popup-button
.
El botón de acción principal
El <button>
visible y enfocado inicialmente se ajusta dentro del contenedor con dos formas de esquina coincidentes para que las interacciones de enfoque, colocación del cursor y activa aparezcan contenidas en .gui-split-button
.
El botón de activación de la ventana emergente
El elemento de compatibilidad "botón emergente" sirve para activar y hacer alusión a la lista de botones secundarios. Observa que no es un <button>
y que no se puede enfocar. Sin embargo, es el ancla de posicionamiento para .gui-popup
y el host para :focus-within
que se usa para presentar la ventana emergente.
La tarjeta emergente
Esta es una tarjeta flotante secundaria de su ancla .gui-popup-button
, posicionada de forma absoluta y une semánticamente la lista de botones.
Las acciones secundarias
Un <button>
enfocado con un tamaño de fuente ligeramente más pequeño que el botón de acción principal tiene un ícono y un estilo complementario al botón principal.
Propiedades personalizadas
Las siguientes variables ayudan a crear armonía de colores y un lugar central para modificar los valores que se usan en todo el 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%);
}
}
Diseños y colores
Marca
El elemento comienza como un <div>
con un nombre de clase personalizado.
<div class="gui-split-button"></div>
Agrega el botón principal y los 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>
Observa los atributos aria aria-haspopup
y aria-expanded
. Estos indicadores son fundamentales para que los lectores de pantalla conozcan la capacidad y el estado de la experiencia del botón dividido. El atributo title
es útil para todos.
Agrega un ícono <svg>
y el elemento de contenedor .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 una ubicación de ventana emergente directa, .gui-popup
es un elemento secundario del botón que la expande. El único atractivo con esta estrategia es que el contenedor .gui-split-button
no puede usar overflow: hidden
, ya que recortará la ventana emergente para que no esté presente visualmente.
Un <ul>
lleno de contenido <li><button>
se anunciará como una "lista de botones" a los lectores de pantalla, que es precisamente la interfaz que se presenta.
<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 darle estilo y divertirme con los colores, agregué íconos a los botones secundarios de https://heroicons.com. Los íconos son opcionales para los botones principales y secundarios.
<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
Con HTML y contenido en su lugar, los estilos están listos para proporcionar color y diseño.
Aplica diseño al contenedor del botón dividido
Un tipo de pantalla inline-flex
funciona bien para este componente de unión, ya que debe ajustarse intercalado con otros botones, acciones o elementos de división.
.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;
}
El estilo <button>
Los botones son muy buenos para ocultar la cantidad de código que se requiere. Es posible que debas deshacer o reemplazar los estilos predeterminados del navegador, pero también deberás aplicar alguna herencia, agregar estados de interacción y adaptarte a varios tipos de entrada y preferencias del usuario. Los estilos de botones se acumulan rápidamente.
Estos botones son diferentes de los normales porque comparten un fondo con un elemento superior. Por lo general, un botón posee el color de fondo y del texto. Sin embargo, estos lo comparten y solo aplican su propio fondo en la interacción.
.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;
}
Agrega estados de interacción con algunas pseudoclases de CSS y usa propiedades personalizadas coincidentes para el 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);
}
}
El botón principal necesita algunos estilos especiales para completar el efecto de diseño:
.gui-split-button > button {
border-end-start-radius: var(--radius);
border-start-start-radius: var(--radius);
& > svg {
fill: none;
stroke: var(--ontheme);
}
}
Por último, para darle un poco de estilo, el botón y el ícono del tema claro tienen una 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));
}
}
}
Un buen botón es aquel que presta atención a las microinteracciones y a los pequeños detalles.
Nota sobre :focus-visible
Observa cómo los estilos de botones usan :focus-visible
en lugar de :focus
. :focus
es un toque crucial para crear una interfaz de usuario accesible, pero tiene un inconveniente: no es inteligente en cuanto a si el usuario necesita verla o no, se aplicará a cualquier enfoque.
En el siguiente video, se intenta desglosar esta microinteracción para mostrar cómo :focus-visible
es una alternativa inteligente.
Aplica diseño al botón emergente
Un flexbox 4ch
para centrar un ícono y fijar una lista de botones emergentes. Al igual que el botón principal, es transparente hasta que se coloca el cursor sobre él o se interactúa con él, y se estira para llenar el espacio.
.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);
}
Aplica capas en los estados de desplazamiento del mouse, enfoque y activo con el anidado de CSS y el selector 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);
}
}
Estos estilos son el hook principal para mostrar y ocultar la ventana emergente. Cuando .gui-popup-button
tenga focus
en cualquiera de sus elementos secundarios, configura opacity
, la posición y pointer-events
en el ícono y la ventana emergente.
.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;
}
}
}
Con los estilos de entrada y salida completos, lo último que debes hacer es transformar las transiciones de forma condicional según la preferencia de movimiento del usuario:
.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;
}
}
}
Si observas el código con atención, notarás que la opacidad aún se transfiere para los usuarios que prefieren reducir el movimiento.
Cómo aplicar diseño a la ventana emergente
El elemento .gui-popup
es una lista de botones de tarjetas flotantes que usa propiedades personalizadas y unidades relativas para que sean un poco más pequeñas, coincidan de forma interactiva con el botón principal y coincidan con la marca con su uso del color. Observa que los íconos tienen menos contraste, son más delgados y la sombra tiene un toque de azul de la marca. Al igual que con los botones, una IU y UX sólidas son el resultado de la acumulación de estos pequeños detalles.
.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%))
;
}
Los íconos y los botones tienen colores de marca para que se vean bien en cada tarjeta con temas oscuros y claros:
.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%;
}
}
La ventana emergente del tema oscuro tiene sombras de texto e íconos, además de una sombra de cuadro un poco más 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 de íconos <svg>
genéricos
Todos los íconos tienen un tamaño relativo al botón font-size
en el que se usan con la unidad ch
como inline-size
. A cada uno también se le asignan algunos estilos para ayudar a definir los íconos de forma suave y fluida.
.gui-split-button svg {
inline-size: 2ch;
box-sizing: content-box;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2px;
}
Diseño de derecha a izquierda
Las propiedades lógicas realizan todo el trabajo complejo.
Esta es la lista de propiedades lógicas que se usan:
- display: inline-flex
crea un elemento flex intercalado.
- padding-block
y padding-inline
como un par, en lugar de la abreviatura padding
, obtienen los beneficios de rellenar los lados lógicos.
- border-end-start-radius
y
amigos redondearán las esquinas según la dirección del documento.
- inline-size
en lugar de width
garantiza que el tamaño no esté vinculado a dimensiones físicas.
- border-inline-start
agrega un borde al inicio, que puede estar a la derecha o a la izquierda, según la dirección de la secuencia de comandos.
JavaScript
Casi todo el siguiente código JavaScript está destinado a mejorar la accesibilidad. Dos de mis bibliotecas auxiliares se usan para facilitar un poco las tareas. BlingBlingJS se usa para realizar consultas DOM concisas y configurar objetos de escucha de eventos de forma sencilla, mientras que roving-ux ayuda a facilitar las interacciones accesibles del teclado y el gamepad para la ventana emergente.
import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'
const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')
Con las bibliotecas anteriores importadas y los elementos seleccionados y guardados en variables, la actualización de la experiencia está a solo unas funciones de completarse.
Índice itinerante
Cuando un teclado o un lector de pantalla enfoca el .gui-popup-button
, queremos desviar el enfoque al primer botón (o al que se enfocó más recientemente) en el .gui-popup
. La biblioteca nos ayuda a hacerlo con los parámetros element
y target
.
popupButtons.forEach(element =>
rovingIndex({
element,
target: 'button',
}))
Ahora, el elemento pasa el enfoque a los elementos secundarios <button>
de destino y habilita la navegación estándar con las teclas de flecha para explorar las opciones.
Activa o desactiva aria-expanded
Si bien es visualmente evidente que se muestra y oculta una ventana emergente, un lector de pantalla necesita más que indicadores visuales. En este caso, se usa JavaScript para complementar la interacción de :focus-within
generada por CSS activando o desactivando un atributo adecuado para el lector de pantalla.
popupButtons.on('focusin', e => {
e.currentTarget.setAttribute('aria-expanded', true)
})
popupButtons.on('focusout', e => {
e.currentTarget.setAttribute('aria-expanded', false)
})
Habilita la tecla Escape
El enfoque del usuario se envió intencionalmente a una trampa, lo que significa que debemos
proporcionar una forma de salir. La forma más común es permitir el uso de la clave Escape
.
Para ello, observa las pulsaciones de teclas en el botón emergente, ya que cualquier evento del teclado en los elementos secundarios se propagará a este elemento superior.
popupButtons.on('keyup', e => {
if (e.code === 'Escape')
e.target.blur()
})
Si el botón emergente ve que se presiona la tecla Escape
, quita el foco con blur()
.
Clics en el botón Dividir
Por último, si el usuario hace clic en los botones, los presiona o interactúa con el teclado, la aplicación debe realizar la acción adecuada. Aquí se vuelve a usar el burbujeo de eventos, pero esta vez en el contenedor .gui-split-button
, para detectar los clics en el botón de una ventana emergente secundaria o la acción principal.
splitButtons.on('click', event => {
if (event.target.nodeName !== 'BUTTON') return
console.info(event.target.innerText)
})
Conclusión
Ahora que sabes cómo lo hice, ¿cómo lo harías tú? 🙂
Diversifiquemos nuestros enfoques y aprendamos todas las formas de desarrollar en la Web. Crea una demo, twittea los vínculos y los agregaré a la sección de remixes de la comunidad a continuación.