Cómo compilar un componente de botón dividido

Descripción general básica de cómo compilar un componente de botón dividido accesible.

En esta publicación, quiero compartir ideas sobre cómo crear un botón dividido . Prueba la demostración.

Demo

Si prefieres un video, aquí tienes una versión de este artículo 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 y, al mismo tiempo, anidar acciones secundarias que se usan con menos frecuencia hasta que se necesiten. Un botón dividido puede ser fundamental para que un diseño ocupado se sienta minimalista. Un botón dividido avanzado puede incluso recordar la última acción del usuario y promoverla a la posición principal.

Puedes encontrar un botón dividido común en tu aplicación de correo electrónico. La acción principal es enviar, pero tal vez puedas enviar el mensaje más tarde o guardar un borrador:

Ejemplo de un botón de división como se ve en una aplicación de correo electrónico.

El área de acción compartida es útil, ya que el usuario no necesita buscar. Saben que las acciones de correo electrónico esenciales se encuentran en el botón dividido.

Piezas

Analicemos las partes esenciales de un botón dividido antes de hablar de 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 se destacan aspectos del HTML, el diseño y la accesibilidad de cada parte principal.

Son los elementos HTML que componen el botón dividido.

Contenedor del botón de división de nivel superior

El componente de nivel más alto es un flexbox intercalado, con una clase de gui-split-button, que contiene la acción principal y el .gui-popup-button.

La clase gui-split-button inspeccionada y que muestra las propiedades CSS que se usan en esta clase.

El botón de acción principal

El <button> inicialmente visible y enfocable se ajusta dentro del contenedor con dos formas de esquina coincidentes para las interacciones de enfoque, desplazamiento y activo para que aparezcan contenidas dentro de .gui-split-button.

El inspector que muestra las reglas de CSS para el elemento del botón.

El botón de activación de la ventana emergente

El elemento de soporte "botón emergente" sirve para activar y hacer alusión a la lista de botones secundarios. Observa que no es un <button> y no se puede enfocar. Sin embargo, es el anclaje de posicionamiento para .gui-popup y el host para :focus-within que se usa para presentar la ventana emergente.

El inspector muestra las reglas de CSS para la clase gui-popup-button.

La tarjeta emergente

Esta es una tarjeta flotante secundaria de su ancla .gui-popup-button, posicionada de forma absoluta y que envuelve semánticamente la lista de botones.

El inspector que muestra las reglas CSS de la clase gui-popup

Las acciones secundarias

Un <button> enfocable con un tamaño de fuente ligeramente más pequeño que el del botón de acción principal incluye un ícono y un estilo complementario al del botón principal.

El inspector que muestra las reglas de CSS para el elemento del botón.

Propiedades personalizadas

Las siguientes variables ayudan a crear armonía de color 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 color

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. Estas indicaciones 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 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 colocación sencilla de la ventana emergente, .gui-popup es un elemento secundario del botón que la expande. El único inconveniente de esta estrategia es que el contenedor .gui-split-button no puede usar overflow: hidden, ya que recortará la ventana emergente y no se mostrará visualmente.

Un <ul> completado con contenido de <li><button> se anunciará como una "lista de botones" para 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 un toque especial y divertirme con el color, 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 el HTML y el contenido en su lugar, los estilos están listos para proporcionar color y diseño.

Cómo diseñar el contenedor del botón desplegable

Un tipo de visualización inline-flex funciona bien para este componente de ajuste, ya que debe ajustarse en línea con otros botones divididos, acciones o 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;
}

Es el botón dividido.

El diseño de <button>

Los botones son muy buenos para disfrazar 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 cierta herencia, agregar estados de interacción y adaptarte a diversas preferencias del usuario y tipos de entrada. Los estilos de los botones se acumulan rápidamente.

Estos botones son diferentes de los botones normales porque comparten un fondo con un elemento principal. Por lo general, un botón posee su color de fondo y de 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 toque especial, 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 tiene en cuenta las microinteracciones y los detalles pequeños.

Nota sobre :focus-visible

Observa cómo los diseños de los 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 sobre si el usuario necesita verlo o no, se aplicará para cualquier enfoque.

En el siguiente video, se intenta desglosar esta microinteracción para mostrar cómo :focus-visible es una alternativa inteligente.

Cómo diseñar el botón emergente

Un 4ch flexbox para centrar un ícono y anclar 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 rellenar el espacio.

Es la parte de flecha del botón desplegable que se usa para activar la ventana emergente.

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

Agrega estados de desplazamiento, enfoque y activo con anidamiento 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 son los principales estilos para mostrar y ocultar la ventana emergente. Cuando el .gui-popup-button tiene focus en cualquiera de sus elementos secundarios, establece 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, la última parte es condicionar las transiciones de transformación 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 sigue teniendo una transición para los usuarios que prefieren un movimiento reducido.

Cómo definir el diseño de la ventana emergente

El elemento .gui-popup es una lista de botones de tarjetas flotantes que usa propiedades personalizadas y unidades relativas para ser sutilmente más pequeño, coincidir de forma interactiva con el botón principal y estar alineado con la marca por 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 una UX sólidas son el resultado de la acumulación de estos pequeños detalles.

Es un elemento de tarjeta flotante.

.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 la marca para que se vean bien en cada tarjeta con tema oscuro y claro:

Vínculos e íconos para la confirmación de compra, el Pago rápido y la opción Guardar para más adelante

.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 texto y sombras de íconos, además de una sombra de caja un poco más intensa:

La ventana emergente en el tema oscuro.

.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 genéricos de <svg>

Todos los íconos tienen un tamaño relativo al botón font-size en el que se usan, ya que se usa la unidad ch como inline-size. Cada uno también recibe algunos estilos para ayudar a delinear los íconos de forma suave y uniforme.

.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. A continuación, se incluye la lista de propiedades lógicas que se usan: - display: inline-flex crea un elemento flexible intercalado. - padding-block y padding-inline como un par, en lugar de la abreviatura padding, obtienen los beneficios del padding de los lados lógicos. - border-end-start-radius y friends 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 del guion.

JavaScript

Casi todo el siguiente código JavaScript sirve para mejorar la accesibilidad. Dos de mis bibliotecas de ayuda se usan para facilitar un poco las tareas. BlingBlingJS se usa para consultas concisas del DOM y una configuración sencilla del objeto de escucha de eventos, mientras que roving-ux ayuda a facilitar las interacciones accesibles con el 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 pocas funciones de completarse.

Índice de itinerancia

Cuando un teclado o un lector de pantalla enfocan el .gui-popup-button, queremos reenviar el enfoque al primer botón (o al que se enfocó más recientemente) del .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.

Cómo activar o desactivar aria-expanded

Si bien es visualmente evidente que una ventana emergente se muestra y se oculta, un lector de pantalla necesita más que indicaciones visuales. Aquí se usa JavaScript para complementar la interacción :focus-within controlada por CSS alternando un atributo apropiado 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)
})

Cómo habilitar la clave 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 de teclado en los elementos secundarios se propagará a este elemento principal.

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

Si el botón emergente detecta alguna presión de tecla Escape, quita el enfoque de sí mismo con blur().

Clics en el botón de división

Por último, si el usuario hace clic, presiona o interactúa con los botones a través del teclado, la aplicación debe realizar la acción correspondiente. Aquí se vuelve a usar la propagación de eventos, pero esta vez en el contenedor .gui-split-button, para detectar los clics en los botones 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 crear contenido en la Web. Crea una demostración, envíame por Twitter los vínculos y la agregaré a la sección de remixes de la comunidad que se encuentra a continuación.

Remixes de la comunidad