Cómo compilar un componente de botón dividido

Una descripción general fundamental de cómo compilar un componente accesible de botón dividido

En esta publicación, quiero compartir ideas sobre una forma de crear un botón dividido . Prueba la demostración.

Demostración

Si prefieres ver un video, aquí tienes una versión de YouTube de esta publicación:

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 se anidan acciones secundarias y menos usadas hasta que son necesarias. Un botón dividido puede ser crucial para ayudar a que un diseño recargado se sienta mínimo. Un botón de división avanzada incluso puede recordar la última acción del usuario y promoverla a la posición principal.

Encontrarás 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 enviar más tarde o guardar un borrador:

Un ejemplo de botón dividido como se ve en una aplicación de correo electrónico.

El área de acción compartida es agradable, ya que el usuario no necesita mirar a su alrededor. Saben que las acciones esenciales de correo electrónico están contenidas en el botón para dividir.

Partes

Analicemos las partes esenciales de un botón de división antes de analizar su organizació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.

Son los elementos HTML que conforman el botón Dividir.

Contenedor del botón dividido 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.

Se inspecciona la clase gui-split-button y se muestran las propiedades de CSS usadas en esta clase.

El botón de acción principal

El <button> inicialmente visible y enfocable se ajusta al contenedor con dos formas de esquinas coincidentes para que las interacciones de enfoque, hover y activas aparezcan en .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 compatibilidad "botón emergente" se usa para activar y aludir a la lista de botones secundarios. Ten en cuenta que no es una <button> ni es enfocable. Sin embargo, es el ancla de posicionamiento para .gui-popup y el host de :focus-within que se usa para presentar la ventana emergente.

El inspector que muestra las reglas de CSS del botón gui-popup de la clase.

La tarjeta emergente

Es un elemento secundario de tarjeta flotante en su ancla .gui-popup-button, posicionado de manera absoluta y semántica que une la lista de botones.

El inspector que muestra las reglas de CSS de la ventana emergente de la gui de la clase.

Las acciones secundarias

Un objeto <button> enfocable con un tamaño de fuente ligeramente más pequeño que el botón de acción principal incluye un ícono y un estilo complementario 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 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 una <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 de 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 de división. El atributo title es útil para todo el mundo.

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 posición sencilla de una ventana emergente, .gui-popup es un elemento secundario del botón que lo expande. El único problema 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> con contenido de <li><button> se anunciará como una "lista de botones" para los lectores de pantalla, que es exactamente 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 lograr un estilo y divertirse con el color, agregué íconos a los botones secundarios de https://heroicons.com. Los íconos son opcionales tanto para los botones principales como para los 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 tu lugar, los estilos están listos para brindar color y diseño.

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

Un tipo de visualización inline-flex funciona bien para este componente de unión, ya que debe caber 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 botón para dividir.

El estilo de <button>

Los botones son muy buenos para ocultar cuánto código se necesita. Es posible que debas deshacer o reemplazar los diseños predeterminados del navegador, pero también deberás aplicar cierta 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 botones normales porque comparten un fondo con un elemento principal. Por lo general, un botón posee su color de fondo y texto. Sin embargo, estos lo comparten y solo aplican sus propios antecedentes sobre las interacciones.

.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 seudoclases 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 lograr un toque especial, el botón y el ícono del tema claro obtienen 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 ha prestado atención a las microinteracciones y pequeños detalles.

Una nota sobre :focus-visible

Observa cómo los estilos de botones usan :focus-visible en lugar de :focus. :focus es un toque fundamental para crear una interfaz de usuario accesible, pero tiene una desventaja: no es inteligente en cuanto a si el usuario necesita verla o no, sino que aplicará para cualquier enfoque.

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

Cómo aplicar diseño al botón de la ventana emergente

Un Flexbox 4ch 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 llenarse.

La parte de la flecha del botón dividido 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);
}

Coloca capas de desplazamiento, enfoque y estados activos con la anidación 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 completados, la última parte es las transformaciones de transición 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 prestas atención al código, se dará cuenta de que la opacidad aún está en transición para los usuarios que prefieren un movimiento reducido.

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 ser sutilmente más pequeñas, coincidir de forma interactiva con el botón principal y según la marca con su uso del color. Ten en cuenta que los íconos tienen menos contraste, son más delgados y la sombra tiene un toque de azul marca. Al igual que con los botones, una buena IU y UX es el resultado de la acumulación de pequeños detalles.

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 botones tienen colores de marca para darle un estilo agradable dentro de cada tarjeta temática oscura y clara:

Íconos y vínculos de confirmación de la compra, Pago rápido y 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 adiciones de texto y de sombras de íconos, además de una sombra de cuadro ligeramente más intensa:

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 ícono genéricos <svg>

Todos los íconos tienen un tamaño relativo al botón font-size en el que se usan si se utiliza la unidad ch como inline-size. A cada uno también se le dan algunos estilos para ayudar a delinear los íconos suaves y fluidos.

.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 hacen todo el trabajo complejo. Esta es la lista de propiedades lógicas usadas: - display: inline-flex crea un elemento flexible intercalado. - padding-block y padding-inline como 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 de JavaScript tiene como objetivo mejorar la accesibilidad. Dos de mis bibliotecas auxiliares se usan para facilitar un poco las tareas. BlingBlingJS se usa para consultas breves del DOM y una configuración sencilla del objeto de escucha de eventos, mientras que roving-ux ayuda a facilitar las interacciones accesibles del teclado y el control de juegos 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 pocas funciones de completarse.

Índice móvil

Cuando un teclado o un lector de pantalla enfoca la .gui-popup-button, debemos reenviar el enfoque al primer botón (o al que se enfoca más recientemente) en .gui-popup. La biblioteca nos ayuda a hacerlo con los parámetros element y target.

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

El elemento ahora pasa el enfoque a los elementos secundarios <button> de destino y habilita la navegación estándar con teclas de flecha para explorar las opciones.

Activando o desactivando aria-expanded

Si bien es visualmente evidente que se muestra y se oculta una ventana emergente, un lector de pantalla necesita más que señales visuales. Aquí se usa JavaScript para complementar la interacción :focus-within basada en CSS mediante la activación o desactivación de un atributo adecuado del 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 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, presta atención a las pulsaciones de teclas en el botón emergente, ya que cualquier evento de teclado de los elementos secundarios aparecerá en este elemento superior.

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

Si se presiona la tecla Escape en el botón emergente, se quita el enfoque con blur().

Clics en el botón dividido

Por último, si el usuario hace clic, presiona o interactúa con los botones, la aplicación debe realizar la acción adecuada. La creación de burbujas de eventos se vuelve a usar aquí, pero esta vez en el contenedor .gui-split-button para detectar clics en 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 🙂

Diversifiquemos nuestros enfoques y aprendamos todas las formas de desarrollar en la Web. Crea una demostración, twittea vínculos y la agregaré a la sección de remixes de la comunidad a continuación.

Remixes de la comunidad